From 520175d7e0bfb1a5cdcead626c60d8a60b4a46d1 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 10:36:37 +0100 Subject: [PATCH 001/100] refactor: add createRecursiveIterator() method to Filesystem utility --- src/Utility/Filesystem.php | 77 ++++++++-- tests/TestCase/Utility/FilesystemTest.php | 165 ++++++++++++++++++++++ 2 files changed, 228 insertions(+), 14 deletions(-) diff --git a/src/Utility/Filesystem.php b/src/Utility/Filesystem.php index 161ccfc99ee..f5ed699c93a 100644 --- a/src/Utility/Filesystem.php +++ b/src/Utility/Filesystem.php @@ -79,32 +79,81 @@ public function find(string $path, Closure|string|null $filter = null, ?int $fla */ public function findRecursive(string $path, Closure|string|null $filter = null, ?int $flags = null): Iterator { + $iterator = $this->createRecursiveIterator( + $path, + $flags, + RecursiveIteratorIterator::CHILD_FIRST, + skipHiddenDirs: true, + ); + + if ($filter === null) { + return $iterator; + } + + return $this->filterIterator($iterator, $filter); + } + + /** + * Create a recursive directory iterator with optional filtering. + * + * This is a building block method for creating custom recursive file iteration. + * Consumers can wrap the returned iterator with additional filters or process it directly. + * + * Example: + * ```php + * $filesystem = new Filesystem(); + * $iterator = $filesystem->createRecursiveIterator( + * '/path/to/dir', + * mode: RecursiveIteratorIterator::LEAVES_ONLY, + * customFilter: fn($file) => $file->getExtension() === 'php' + * ); + * + * foreach ($iterator as $file) { + * echo $file->getPathname() . PHP_EOL; + * } + * ``` + * + * @param string $path Directory path. + * @param int|null $flags Flags for FilesystemIterator::__construct(); + * @param int<0, 2> $mode RecursiveIteratorIterator mode (LEAVES_ONLY, SELF_FIRST, CHILD_FIRST). + * @param bool $skipHiddenDirs Whether to skip hidden directories (default: true). + * @param \Closure|null $customFilter Optional custom filter callback for RecursiveCallbackFilterIterator. + * Receives SplFileInfo, returns bool. Combined with hidden directory filtering if enabled. + * @return \RecursiveIteratorIterator + */ + public function createRecursiveIterator( + string $path, + ?int $flags = null, + int $mode = RecursiveIteratorIterator::CHILD_FIRST, + bool $skipHiddenDirs = true, + ?Closure $customFilter = null, + ): RecursiveIteratorIterator { $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()) { + // Apply filtering if needed + if ($skipHiddenDirs || $customFilter !== null) { + $filterCallback = function (SplFileInfo $current) use ($skipHiddenDirs, $customFilter): bool { + // Skip hidden directories if enabled + if ($skipHiddenDirs && str_starts_with($current->getFilename(), '.') && $current->isDir()) { return false; } - return true; - }, - ); + // Apply custom filter if provided + if ($customFilter !== null) { + return $customFilter($current); + } - $flatten = new RecursiveIteratorIterator( - $dirFilter, - RecursiveIteratorIterator::CHILD_FIRST, - ); + return true; + }; - if ($filter === null) { - return $flatten; + $directory = new RecursiveCallbackFilterIterator($directory, $filterCallback); } - return $this->filterIterator($flatten, $filter); + return new RecursiveIteratorIterator($directory, $mode); } /** diff --git a/tests/TestCase/Utility/FilesystemTest.php b/tests/TestCase/Utility/FilesystemTest.php index 6becea12457..986b6f921c1 100644 --- a/tests/TestCase/Utility/FilesystemTest.php +++ b/tests/TestCase/Utility/FilesystemTest.php @@ -19,6 +19,7 @@ use Cake\TestSuite\TestCase; use Cake\Utility\Filesystem; use org\bovigo\vfs\vfsStream; +use RecursiveIteratorIterator; /** * Filesystem class @@ -108,4 +109,168 @@ public function testDeleteDirWithLinks(): void $this->assertTrue($this->fs->deleteDir($path)); $this->assertFalse(file_exists($link)); } + + public function testCreateRecursiveIteratorBasic(): void + { + $structure = [ + 'file1.php' => 'content', + 'file2.txt' => 'content', + 'subdir' => [ + 'file3.php' => 'content', + 'file4.txt' => 'content', + ], + ]; + vfsStream::create($structure); + + $iterator = $this->fs->createRecursiveIterator($this->vfsPath); + + $this->assertInstanceOf(RecursiveIteratorIterator::class, $iterator); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('file1.php', $files); + $this->assertContains('file2.txt', $files); + $this->assertContains('file3.php', $files); + $this->assertContains('file4.txt', $files); + } + + public function testCreateRecursiveIteratorSkipsHiddenDirs(): void + { + $structure = [ + 'visible.php' => 'content', + '.hidden' => [ + 'secret.php' => 'should not see this', + ], + 'subdir' => [ + 'visible2.php' => 'content', + ], + ]; + vfsStream::create($structure); + + $iterator = $this->fs->createRecursiveIterator($this->vfsPath, skipHiddenDirs: true); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('visible.php', $files); + $this->assertContains('visible2.php', $files); + $this->assertNotContains('secret.php', $files); + $this->assertNotContains('.hidden', $files); + } + + public function testCreateRecursiveIteratorWithCustomFilter(): void + { + $structure = [ + 'file1.php' => 'content', + 'file2.txt' => 'content', + 'subdir' => [ + 'file3.php' => 'content', + 'file4.txt' => 'content', + ], + ]; + vfsStream::create($structure); + + // Filter to only include .php files + // Note: Must allow directories to pass for recursion to work + $filter = fn($file) => $file->isDir() || $file->getExtension() === 'php'; + $iterator = $this->fs->createRecursiveIterator( + $this->vfsPath, + customFilter: $filter, + ); + + $files = []; + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + $this->assertContains('file1.php', $files); + $this->assertContains('file3.php', $files); + $this->assertNotContains('file2.txt', $files); + $this->assertNotContains('file4.txt', $files); + } + + public function testCreateRecursiveIteratorWithDifferentModes(): void + { + $structure = [ + 'file1.php' => 'content', + 'subdir' => [ + 'file2.php' => 'content', + ], + ]; + vfsStream::create($structure); + + // Test LEAVES_ONLY mode + $iterator = $this->fs->createRecursiveIterator( + $this->vfsPath, + mode: RecursiveIteratorIterator::LEAVES_ONLY, + ); + + $files = []; + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + $this->assertContains('file1.php', $files); + $this->assertContains('file2.php', $files); + } + + public function testFindRecursiveStillWorks(): void + { + $structure = [ + 'file1.php' => 'content', + 'file2.txt' => 'content', + '.hidden' => [ + 'secret.php' => 'should not see this', + ], + 'subdir' => [ + 'file3.php' => 'content', + ], + ]; + vfsStream::create($structure); + + $iterator = $this->fs->findRecursive($this->vfsPath); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('file1.php', $files); + $this->assertContains('file2.txt', $files); + $this->assertContains('file3.php', $files); + // Hidden directories should be skipped + $this->assertNotContains('.hidden', $files); + $this->assertNotContains('secret.php', $files); + } + + public function testCreateRecursiveIteratorAllowsHiddenDirs(): void + { + $structure = [ + 'visible.php' => 'content', + '.hidden' => [ + 'secret.php' => 'content in hidden dir', + ], + ]; + vfsStream::create($structure); + + $iterator = $this->fs->createRecursiveIterator($this->vfsPath, skipHiddenDirs: false); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('visible.php', $files); + $this->assertContains('.hidden', $files); + $this->assertContains('secret.php', $files); + } } From fc660e3e810944b473c6df2b49e04ddb55fc81db Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 14:21:36 +0100 Subject: [PATCH 002/100] default skipHiddenDirs to false --- src/Utility/Filesystem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Utility/Filesystem.php b/src/Utility/Filesystem.php index f5ed699c93a..3becdfa287e 100644 --- a/src/Utility/Filesystem.php +++ b/src/Utility/Filesystem.php @@ -125,7 +125,7 @@ public function createRecursiveIterator( string $path, ?int $flags = null, int $mode = RecursiveIteratorIterator::CHILD_FIRST, - bool $skipHiddenDirs = true, + bool $skipHiddenDirs = false, ?Closure $customFilter = null, ): RecursiveIteratorIterator { $flags ??= FilesystemIterator::KEY_AS_PATHNAME From 57d46e616d3f16891209e64e8140e83118a9b9ae Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 15:45:20 +0100 Subject: [PATCH 003/100] Introduce fluent filesystem utility --- src/Filesystem/Finder.php | 453 +++++++++++++++ src/Filesystem/Path.php | 147 +++++ tests/TestCase/Filesystem/FinderTest.php | 693 +++++++++++++++++++++++ tests/TestCase/Filesystem/PathTest.php | 77 +++ 4 files changed, 1370 insertions(+) create mode 100644 src/Filesystem/Finder.php create mode 100644 src/Filesystem/Path.php create mode 100644 tests/TestCase/Filesystem/FinderTest.php create mode 100644 tests/TestCase/Filesystem/PathTest.php diff --git a/src/Filesystem/Finder.php b/src/Filesystem/Finder.php new file mode 100644 index 00000000000..7664aa727cd --- /dev/null +++ b/src/Filesystem/Finder.php @@ -0,0 +1,453 @@ +in('src') + * ->name('*.php') + * ->exclude('vendor') + * ->files(); + * + * foreach ($files as $file) { + * echo $file->getPathname(); + * } + * ``` + */ +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; + + /** + * The internal filesystem utility + * + * @var \Cake\Utility\Filesystem + */ + protected FilesystemUtil $filesystem; + + /** + * Constructor + */ + public function __construct() + { + $this->filesystem = new FilesystemUtil(); + } + + /** + * 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 depth condition. + * + * @param string $condition Depth condition (e.g., '== 0', '< 3') + * @return $this + */ + public function depth(string $condition) + { + $this->depths[] = $condition; + + 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; + } + + /** + * Get files matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + public function files(): Iterator + { + foreach ($this->paths as $path) { + $iterator = $this->filesystem->createRecursiveIterator( + $path, + mode: RecursiveIteratorIterator::LEAVES_ONLY, + skipHiddenDirs: $this->ignoreHiddenFiles, + customFilter: $this->buildFilter(), + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile()) { + continue; + } + + if ($this->ignoreHiddenFiles && str_starts_with($file->getFilename(), '.')) { + continue; + } + + if (!$this->matchesNamePatterns($file)) { + continue; + } + + if (!$this->matchesPathPatterns($file)) { + continue; + } + + if (!$this->matchesDepth($file, $path)) { + continue; + } + + if (!$this->matchesGlobPatterns($file, $path)) { + continue; + } + + yield $file; + } + } + } + + /** + * Build a filter callback for the recursive iterator. + * + * @return \Closure|null + */ + protected function buildFilter(): ?Closure + { + if ($this->exclude === [] && $this->notPathPatterns === []) { + return null; + } + + return function (SplFileInfo $file): bool { + // Check excluded directories + foreach ($this->exclude as $excluded) { + if ($file->isDir() && str_contains($file->getPathname(), DIRECTORY_SEPARATOR . $excluded)) { + return false; + } + } + + // Check excluded path patterns + foreach ($this->notPathPatterns as $pattern) { + if (str_contains($file->getPathname(), $pattern)) { + return false; + } + } + + return true; + }; + } + + /** + * Check if file matches name patterns. + * + * @param \SplFileInfo $file The file to check + * @return bool + */ + protected function matchesNamePatterns(SplFileInfo $file): bool + { + $filename = $file->getFilename(); + + // Check negative patterns first + foreach ($this->notNames as $pattern) { + if (Path::matches($pattern, $filename)) { + return false; + } + } + + // If no positive patterns, accept all + if ($this->names === []) { + return true; + } + + // Must match at least one positive pattern + foreach ($this->names as $pattern) { + if (Path::matches($pattern, $filename)) { + return true; + } + } + + return false; + } + + /** + * Check if file matches path patterns. + * + * @param \SplFileInfo $file The file to check + * @return bool + */ + protected function matchesPathPatterns(SplFileInfo $file): bool + { + // Must match all include patterns + if ($this->pathPatterns !== []) { + $matched = false; + foreach ($this->pathPatterns as $pattern) { + if (str_contains($file->getPathname(), $pattern)) { + $matched = true; + break; + } + } + if (!$matched) { + return false; + } + } + + return true; + } + + /** + * Check if file matches glob patterns. + * + * @param \SplFileInfo $file The file to check + * @param string $basePath The base path to calculate relative path from + * @return bool + */ + protected function matchesGlobPatterns(SplFileInfo $file, string $basePath): bool + { + if ($this->globPatterns === []) { + return true; + } + + $relativePath = Path::makeRelative($file->getPathname(), $basePath); + + foreach ($this->globPatterns as $pattern) { + if (Path::matches($pattern, $relativePath)) { + return true; + } + } + + return false; + } + + /** + * Check if file matches depth conditions. + * + * @param \SplFileInfo $file The file to check + * @param string $basePath The base path to calculate depth from + * @return bool + */ + protected function matchesDepth(SplFileInfo $file, string $basePath): bool + { + if ($this->depths === []) { + return true; + } + + $basePath = Path::normalize($basePath); + $filePath = Path::normalize($file->getPath()); + $relativePath = Path::makeRelative($filePath, $basePath); + + $depth = $relativePath === '' ? 0 : count(explode('/', $relativePath)); + + foreach ($this->depths as $condition) { + if (!$this->evaluateDepthCondition($depth, $condition)) { + return false; + } + } + + return true; + } + + /** + * Evaluate a depth condition. + * + * @param int $depth The actual depth + * @param string $condition The condition to evaluate (e.g., '== 0', '< 3') + * @return bool + */ + protected function evaluateDepthCondition(int $depth, string $condition): bool + { + $condition = trim($condition); + + if (preg_match('/^(==|!=|<|>|<=|>=)\s*(\d+)$/', $condition, $matches)) { + $operator = $matches[1]; + $value = (int)$matches[2]; + + return match ($operator) { + '==' => $depth === $value, + '!=' => $depth !== $value, + '<' => $depth < $value, + '>' => $depth > $value, + '<=' => $depth <= $value, + default => $depth >= $value, + }; + } + + return true; + } +} diff --git a/src/Filesystem/Path.php b/src/Filesystem/Path.php new file mode 100644 index 00000000000..0611fd2bd44 --- /dev/null +++ b/src/Filesystem/Path.php @@ -0,0 +1,147 @@ +root = vfsStream::setup('root', null, [ + 'src' => [ + 'Controller' => [ + 'AppController.php' => ' ' [ + 'Entity' => [ + 'User.php' => ' [ + 'UsersTable.php' => ' [ + 'layout.php' => ' [ + 'TestCase' => [ + 'Controller' => [ + 'UsersControllerTest.php' => ' [ + '.htaccess' => 'rules', + 'index.php' => ' [ + 'style.css' => 'body {}', + ], + 'js' => [ + 'app.js' => 'console.log();', + ], + ], + ]); + } + + public function testBasicFind(): void + { + $finder = new Finder(); + $files = $finder->in(vfsStream::url('root/src'))->files(); + + $this->assertInstanceOf(Iterator::class, $files); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $this->assertCount(5, $paths); + $this->assertStringContainsString('AppController.php', implode(',', $paths)); + $this->assertStringContainsString('UsersController.php', implode(',', $paths)); + $this->assertStringContainsString('User.php', implode(',', $paths)); + } + + public function testMultiplePaths(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->in(vfsStream::url('root/tests')) + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + $this->assertEquals(6, $count); + } + + public function testNamePattern(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('*Controller.php') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + $this->assertCount(2, $paths); + $this->assertContains('AppController.php', $paths); + $this->assertContains('UsersController.php', $paths); + } + + public function testExclude(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->exclude('tests') + ->exclude('webroot') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $this->assertStringNotContainsString('TestCase', implode(',', $paths)); + $this->assertStringNotContainsString('webroot', implode(',', $paths)); + $this->assertStringContainsString('src', implode(',', $paths)); + } + + public function testDepth(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->depth('== 0') + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + // No files at depth 0 in src directory + $this->assertEquals(0, $count); + + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->depth('== 1') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + // Should find files in Controller/ and View/ + $this->assertGreaterThan(0, count($paths)); + } + + public function testPath(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->path('Controller') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $this->assertStringContainsString('Controller', implode(',', $paths)); + $this->assertStringNotContainsString('Model', implode(',', $paths)); + } + + public function testNotPath(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->notPath('Controller') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $this->assertStringNotContainsString('Controller', implode(',', $paths)); + $this->assertStringContainsString('Model', implode(',', $paths)); + } + + public function testIgnoreHiddenFiles(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/webroot')) + ->ignoreHiddenFiles(false) + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + $this->assertContains('.htaccess', $paths); + } + + public function testChaining(): void + { + $finder = new Finder(); + $result = $finder + ->in(vfsStream::url('root/src')) + ->name('*.php') + ->exclude('View') + ->depth('< 3'); + + $this->assertInstanceOf(Finder::class, $result); + } + + public function testGlobPatternSimpleWildcard(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->name('*.css') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + $this->assertContains('style.css', $paths); + $this->assertCount(1, $paths); + } + + public function testGlobPatternRecursiveWildcard(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('*.php') + ->files(); + + // Should find all PHP files recursively (name filters filename only) + $count = 0; + foreach ($files as $file) { + $count++; + } + + $this->assertEquals(5, $count); + } + + public function testGlobPatternWithPath(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('User*.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('User.php', $filenames); + $this->assertContains('UsersTable.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertCount(3, $filenames); + } + + public function testGlobPatternMultipleExtensions(): void + { + $finder = new Finder(); + + // Test .js files + $jsFiles = $finder + ->in(vfsStream::url('root/webroot')) + ->name('*.js') + ->files(); + + $count = 0; + foreach ($jsFiles as $file) { + $this->assertEquals('js', $file->getExtension()); + $count++; + } + $this->assertEquals(1, $count); + + // Test .css files + $finder2 = new Finder(); + $cssFiles = $finder2 + ->in(vfsStream::url('root/webroot')) + ->name('*.css') + ->files(); + + $count = 0; + foreach ($cssFiles as $file) { + $this->assertEquals('css', $file->getExtension()); + $count++; + } + $this->assertEquals(1, $count); + } + + public function testGlobPatternWithExclude(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('*.php') + ->exclude('Controller') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $pathString = implode(',', $paths); + $this->assertStringNotContainsString('Controller', $pathString); + $this->assertStringContainsString('Model', $pathString); + } + + public function testGlobPatternSpecificDirectory(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->path('Controller') + ->name('*Controller.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('AppController.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertCount(2, $filenames); + } + + public function testPatternMethod(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->pattern('src/**/*.php') + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + // Should find all PHP files under src/ + $this->assertEquals(5, $count); + } + + public function testPatternMethodControllers(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->pattern('src/Controller/*.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('AppController.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertCount(2, $filenames); + } + + public function testPatternMethodMultiple(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->pattern('src/**/*Controller.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('AppController.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertCount(2, $filenames); + } + + public function testPatternMethodCssFiles(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->pattern('webroot/**/*.css') + ->files(); + + $count = 0; + foreach ($files as $file) { + $this->assertEquals('css', $file->getExtension()); + $count++; + } + + $this->assertEquals(1, $count); + } + + public function testPatternWithExclude(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->pattern('src/**/*.php') + ->exclude('Controller') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $pathString = implode(',', $paths); + $this->assertStringNotContainsString('Controller', $pathString); + $this->assertStringContainsString('Model', $pathString); + } + + public function testPatternWithDepth(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->pattern('**/*.php') + ->depth('== 1') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + // Should find files at depth 1 (src/Controller/, src/View/) + $this->assertContains('AppController.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertContains('layout.php', $filenames); + } + + public function testMultiplePatterns(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->pattern('**/*.css') + ->pattern('**/*.js') + ->files(); + + $extensions = []; + foreach ($files as $file) { + $extensions[] = $file->getExtension(); + } + + $this->assertContains('css', $extensions); + $this->assertContains('js', $extensions); + $this->assertCount(2, $extensions); + } + + public function testComplexFiltering(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('*.php') + ->path('Model') + ->notPath('Entity') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $pathString = implode(',', $paths); + $this->assertStringContainsString('Model', $pathString); + $this->assertStringContainsString('Table', $pathString); + $this->assertStringNotContainsString('Entity', $pathString); + } + + public function testMultipleInPaths(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src/Controller')) + ->in(vfsStream::url('root/src/View')) + ->name('*.php') + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + // 2 controllers + 1 view file + $this->assertEquals(3, $count); + } + + public function testPatternWithMultipleInPaths(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->in(vfsStream::url('root/tests')) + ->pattern('**/*Controller*.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('AppController.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertContains('UsersControllerTest.php', $filenames); + } + + public function testNoMatches(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->pattern('**/*.nonexistent') + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + $this->assertEquals(0, $count); + } + + public function testNameAndPathCombination(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->name('User*.php') + ->path('Table') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('UsersTable.php', $filenames); + $this->assertNotContains('UsersController.php', $filenames); + $this->assertCount(1, $filenames); + } + + public function testDepthZero(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/webroot')) + ->depth('== 0') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('index.php', $filenames); + $this->assertNotContains('style.css', $filenames); // In subdirectory + } + + public function testExcludeMultiple(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->name('*.php') + ->exclude('tests') + ->exclude('webroot') + ->exclude('View') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + $pathString = implode(',', $paths); + $this->assertStringNotContainsString('tests', $pathString); + $this->assertStringNotContainsString('webroot', $pathString); + $this->assertStringNotContainsString('View', $pathString); + $this->assertStringContainsString('Controller', $pathString); + } + + public function testNotName(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('*.php') + ->notName('*Controller.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('User.php', $filenames); + $this->assertContains('UsersTable.php', $filenames); + $this->assertContains('layout.php', $filenames); + $this->assertNotContains('AppController.php', $filenames); + $this->assertNotContains('UsersController.php', $filenames); + } + + public function testNotNameMultiple(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/webroot')) + ->name('*.*') + ->notName('*.php') + ->notName('.htaccess') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertContains('style.css', $filenames); + $this->assertContains('app.js', $filenames); + $this->assertNotContains('index.php', $filenames); + $this->assertNotContains('.htaccess', $filenames); + } + + public function testNameWithNotName(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('User*.php') + ->notName('*Table.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + // Should match User*.php (User.php, UsersController.php) but exclude *Table.php (UsersTable.php) + $this->assertContains('User.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertNotContains('UsersTable.php', $filenames); + $this->assertCount(2, $filenames); + } +} diff --git a/tests/TestCase/Filesystem/PathTest.php b/tests/TestCase/Filesystem/PathTest.php new file mode 100644 index 00000000000..516f329d4df --- /dev/null +++ b/tests/TestCase/Filesystem/PathTest.php @@ -0,0 +1,77 @@ +assertSame('path/to/file', Path::normalize('path\to\file')); + $this->assertSame('path/to/file', Path::normalize('path/to/file')); + $this->assertSame('C:/Windows/System', Path::normalize('C:\Windows\System')); + } + + public function testNormalizeWithTrailing(): void + { + $this->assertSame('path/to/dir', Path::normalize('path/to/dir/')); + $this->assertSame('path/to/dir/', Path::normalize('path/to/dir/', trailing: true)); + $this->assertSame('path/to/dir/', Path::normalize('path/to/dir', trailing: true)); + $this->assertSame('/', Path::normalize('/', trailing: true)); + $this->assertSame('', Path::normalize('/')); + } + + public function testMakeRelative(): void + { + $this->assertSame('src/Model/Table.php', Path::makeRelative('/var/www/src/Model/Table.php', '/var/www')); + $this->assertSame('src/file.php', Path::makeRelative('/var/www/src/file.php', '/var/www/')); + $this->assertSame('', Path::makeRelative('/var/www', '/var/www')); + + // Cross-platform + $this->assertSame('src/file.php', Path::makeRelative('C:\project\src\file.php', 'C:\project')); + + // Going up directories + $this->assertSame('../other/path', Path::makeRelative('/var/other/path', '/var/www')); + } + + public function testJoin(): void + { + $this->assertSame('path/to/file', Path::join('path', 'to', 'file')); + $this->assertSame('/absolute/path/file', Path::join('/absolute', 'path', 'file')); + $this->assertSame('path/to/file', Path::join('path/', '/to/', '/file')); + $this->assertSame('path/to/file', Path::join('path\\', '\\to\\', '\\file')); + $this->assertSame('', Path::join()); + $this->assertSame('path', Path::join('path')); + $this->assertSame('path/file', Path::join('path', '', 'file')); + } + + public function testMatches(): void + { + $this->assertTrue(Path::matches('*.php', 'file.php')); + $this->assertFalse(Path::matches('*.php', 'file.txt')); + $this->assertTrue(Path::matches('src/**/*.php', 'src/Model/Table.php')); + $this->assertTrue(Path::matches('vendor/**', 'vendor/lib/file.php')); + $this->assertFalse(Path::matches('tests/**', 'src/file.php')); + $this->assertTrue(Path::matches('src/*/Table.php', 'src/Model/Table.php')); + $this->assertTrue(Path::matches('**/file.php', 'deep/nested/path/file.php')); + } +} From 9f6f120c624ab4665a011652bd060258b0ce3e60 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 16:01:06 +0100 Subject: [PATCH 004/100] Use includeHiddenDirs instead of skipHiddenDirs --- src/Utility/Filesystem.php | 14 +++++++------- tests/TestCase/Utility/FilesystemTest.php | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Utility/Filesystem.php b/src/Utility/Filesystem.php index 3becdfa287e..a570704214f 100644 --- a/src/Utility/Filesystem.php +++ b/src/Utility/Filesystem.php @@ -83,7 +83,7 @@ public function findRecursive(string $path, Closure|string|null $filter = null, $path, $flags, RecursiveIteratorIterator::CHILD_FIRST, - skipHiddenDirs: true, + includeHiddenDirs: false, ); if ($filter === null) { @@ -116,7 +116,7 @@ public function findRecursive(string $path, Closure|string|null $filter = null, * @param string $path Directory path. * @param int|null $flags Flags for FilesystemIterator::__construct(); * @param int<0, 2> $mode RecursiveIteratorIterator mode (LEAVES_ONLY, SELF_FIRST, CHILD_FIRST). - * @param bool $skipHiddenDirs Whether to skip hidden directories (default: true). + * @param bool $includeHiddenDirs Whether to include hidden directories (default: false). * @param \Closure|null $customFilter Optional custom filter callback for RecursiveCallbackFilterIterator. * Receives SplFileInfo, returns bool. Combined with hidden directory filtering if enabled. * @return \RecursiveIteratorIterator @@ -125,7 +125,7 @@ public function createRecursiveIterator( string $path, ?int $flags = null, int $mode = RecursiveIteratorIterator::CHILD_FIRST, - bool $skipHiddenDirs = false, + bool $includeHiddenDirs = false, ?Closure $customFilter = null, ): RecursiveIteratorIterator { $flags ??= FilesystemIterator::KEY_AS_PATHNAME @@ -135,10 +135,10 @@ public function createRecursiveIterator( $directory = new RecursiveDirectoryIterator($path, $flags); // Apply filtering if needed - if ($skipHiddenDirs || $customFilter !== null) { - $filterCallback = function (SplFileInfo $current) use ($skipHiddenDirs, $customFilter): bool { - // Skip hidden directories if enabled - if ($skipHiddenDirs && str_starts_with($current->getFilename(), '.') && $current->isDir()) { + if (!$includeHiddenDirs || $customFilter !== null) { + $filterCallback = function (SplFileInfo $current) use ($includeHiddenDirs, $customFilter): bool { + // Skip hidden directories if not included + if (!$includeHiddenDirs && str_starts_with($current->getFilename(), '.') && $current->isDir()) { return false; } diff --git a/tests/TestCase/Utility/FilesystemTest.php b/tests/TestCase/Utility/FilesystemTest.php index 986b6f921c1..dd649af9ec5 100644 --- a/tests/TestCase/Utility/FilesystemTest.php +++ b/tests/TestCase/Utility/FilesystemTest.php @@ -150,7 +150,7 @@ public function testCreateRecursiveIteratorSkipsHiddenDirs(): void ]; vfsStream::create($structure); - $iterator = $this->fs->createRecursiveIterator($this->vfsPath, skipHiddenDirs: true); + $iterator = $this->fs->createRecursiveIterator($this->vfsPath, includeHiddenDirs: false); $files = []; foreach ($iterator as $file) { @@ -262,7 +262,7 @@ public function testCreateRecursiveIteratorAllowsHiddenDirs(): void ]; vfsStream::create($structure); - $iterator = $this->fs->createRecursiveIterator($this->vfsPath, skipHiddenDirs: false); + $iterator = $this->fs->createRecursiveIterator($this->vfsPath, includeHiddenDirs: true); $files = []; foreach ($iterator as $file) { From 7b23728197966487fdb09167eaef2bbfd5dc4872 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 16:09:46 +0100 Subject: [PATCH 005/100] Improve hidden file test --- src/Filesystem/Finder.php | 2 +- tests/TestCase/Filesystem/FinderTest.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Filesystem/Finder.php b/src/Filesystem/Finder.php index 7664aa727cd..dbce3efff87 100644 --- a/src/Filesystem/Finder.php +++ b/src/Filesystem/Finder.php @@ -249,7 +249,7 @@ public function files(): Iterator $iterator = $this->filesystem->createRecursiveIterator( $path, mode: RecursiveIteratorIterator::LEAVES_ONLY, - skipHiddenDirs: $this->ignoreHiddenFiles, + includeHiddenDirs: !$this->ignoreHiddenFiles, customFilter: $this->buildFilter(), ); diff --git a/tests/TestCase/Filesystem/FinderTest.php b/tests/TestCase/Filesystem/FinderTest.php index 5443b186f6b..527a34bd718 100644 --- a/tests/TestCase/Filesystem/FinderTest.php +++ b/tests/TestCase/Filesystem/FinderTest.php @@ -232,6 +232,24 @@ public function testIgnoreHiddenFiles(): void $this->assertContains('.htaccess', $paths); } + public function testIgnoreHiddenFilesByDefault(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/webroot')) + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + // Hidden files should be ignored by default + $this->assertNotContains('.htaccess', $paths); + $this->assertContains('index.php', $paths); + $this->assertContains('style.css', $paths); + } + public function testChaining(): void { $finder = new Finder(); From c0bd35147948a9f7c8fded5262f2e14f2ba6c98e Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 16:13:44 +0100 Subject: [PATCH 006/100] Escape docblock --- src/Filesystem/Finder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Filesystem/Finder.php b/src/Filesystem/Finder.php index dbce3efff87..885e546bc97 100644 --- a/src/Filesystem/Finder.php +++ b/src/Filesystem/Finder.php @@ -200,9 +200,9 @@ public function notPath(string $pattern) /** * Add a glob pattern for full path matching. * - * Supports wildcards like `src/ ** /*.php` for recursive matching. + * Supports wildcards like `src/**\/*.php` for recursive matching. * - * @param string $pattern Glob pattern (e.g., 'src/ ** /*.php', 'tests/ ** /*Test.php') + * @param string $pattern Glob pattern (e.g., 'src/**\/*.php', 'tests/**\/*Test.php') * @return $this */ public function pattern(string $pattern) From 5793af1ef4cb3b8a01a4048c8d942fbda879f5e7 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 17:20:15 +0100 Subject: [PATCH 007/100] Prevent redundant calls to Path::normalize --- src/Filesystem/Finder.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Filesystem/Finder.php b/src/Filesystem/Finder.php index 885e546bc97..b26b85c2e1f 100644 --- a/src/Filesystem/Finder.php +++ b/src/Filesystem/Finder.php @@ -246,6 +246,8 @@ public function ignoreHiddenFiles(bool $ignore = true) public function files(): Iterator { foreach ($this->paths as $path) { + $normalizedBasePath = Path::normalize($path); + $iterator = $this->filesystem->createRecursiveIterator( $path, mode: RecursiveIteratorIterator::LEAVES_ONLY, @@ -271,11 +273,11 @@ public function files(): Iterator continue; } - if (!$this->matchesDepth($file, $path)) { + if (!$this->matchesDepth($file, $normalizedBasePath)) { continue; } - if (!$this->matchesGlobPatterns($file, $path)) { + if (!$this->matchesGlobPatterns($file, $normalizedBasePath)) { continue; } @@ -375,16 +377,16 @@ protected function matchesPathPatterns(SplFileInfo $file): bool * Check if file matches glob patterns. * * @param \SplFileInfo $file The file to check - * @param string $basePath The base path to calculate relative path from + * @param string $normalizedBasePath The normalized base path to calculate relative path from * @return bool */ - protected function matchesGlobPatterns(SplFileInfo $file, string $basePath): bool + protected function matchesGlobPatterns(SplFileInfo $file, string $normalizedBasePath): bool { if ($this->globPatterns === []) { return true; } - $relativePath = Path::makeRelative($file->getPathname(), $basePath); + $relativePath = Path::makeRelative($file->getPathname(), $normalizedBasePath); foreach ($this->globPatterns as $pattern) { if (Path::matches($pattern, $relativePath)) { @@ -399,18 +401,17 @@ protected function matchesGlobPatterns(SplFileInfo $file, string $basePath): boo * Check if file matches depth conditions. * * @param \SplFileInfo $file The file to check - * @param string $basePath The base path to calculate depth from + * @param string $normalizedBasePath The normalized base path to calculate depth from * @return bool */ - protected function matchesDepth(SplFileInfo $file, string $basePath): bool + protected function matchesDepth(SplFileInfo $file, string $normalizedBasePath): bool { if ($this->depths === []) { return true; } - $basePath = Path::normalize($basePath); $filePath = Path::normalize($file->getPath()); - $relativePath = Path::makeRelative($filePath, $basePath); + $relativePath = Path::makeRelative($filePath, $normalizedBasePath); $depth = $relativePath === '' ? 0 : count(explode('/', $relativePath)); From d96b2de39550e7b35315872b5af4eba2ba08aaf3 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 19:50:55 +0100 Subject: [PATCH 008/100] Also introduce createIterator for non resursive iterators --- src/Utility/Filesystem.php | 100 ++++++++++++++-------- tests/TestCase/Utility/FilesystemTest.php | 49 +++++++++++ 2 files changed, 115 insertions(+), 34 deletions(-) diff --git a/src/Utility/Filesystem.php b/src/Utility/Filesystem.php index a570704214f..26d2039ff6f 100644 --- a/src/Utility/Filesystem.php +++ b/src/Utility/Filesystem.php @@ -55,16 +55,56 @@ class Filesystem */ public function find(string $path, Closure|string|null $filter = null, ?int $flags = null): Iterator { + return $this->createIterator($path, $flags, $filter); + } + + /** + * Create a non-recursive directory iterator with optional filtering. + * + * This is a building block method for creating custom non-recursive file iteration. + * Consumers can wrap the returned iterator with additional filters or process it directly. + * + * Example: + * ```php + * $filesystem = new Filesystem(); + * $iterator = $filesystem->createIterator( + * '/path/to/dir', + * customFilter: fn($file) => $file->getExtension() === 'php' + * ); + * + * foreach ($iterator as $file) { + * echo $file->getPathname() . PHP_EOL; + * } + * ``` + * + * @param string $path Directory path. + * @param int|null $flags Flags for FilesystemIterator::__construct(); + * @param \Closure|string|null $customFilter Optional filter. If string will be used as regex for + * filtering using `RegexIterator`, if callable will be as callback for `CallbackFilterIterator`. + * Receives SplFileInfo, returns bool. + * @return \Iterator + */ + public function createIterator( + string $path, + ?int $flags = null, + Closure|string|null $customFilter = null, + ): Iterator { $flags ??= FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS; + $directory = new FilesystemIterator($path, $flags); - if ($filter === null) { + // Apply filter if provided + if ($customFilter === null) { return $directory; } - return $this->filterIterator($directory, $filter); + if (is_string($customFilter)) { + return new RegexIterator($directory, $customFilter); + } + + return new CallbackFilterIterator($directory, $customFilter); } /** @@ -79,18 +119,13 @@ public function find(string $path, Closure|string|null $filter = null, ?int $fla */ public function findRecursive(string $path, Closure|string|null $filter = null, ?int $flags = null): Iterator { - $iterator = $this->createRecursiveIterator( + return $this->createRecursiveIterator( $path, $flags, RecursiveIteratorIterator::CHILD_FIRST, includeHiddenDirs: false, + customFilter: $filter, ); - - if ($filter === null) { - return $iterator; - } - - return $this->filterIterator($iterator, $filter); } /** @@ -117,34 +152,40 @@ public function findRecursive(string $path, Closure|string|null $filter = null, * @param int|null $flags Flags for FilesystemIterator::__construct(); * @param int<0, 2> $mode RecursiveIteratorIterator mode (LEAVES_ONLY, SELF_FIRST, CHILD_FIRST). * @param bool $includeHiddenDirs Whether to include hidden directories (default: false). - * @param \Closure|null $customFilter Optional custom filter callback for RecursiveCallbackFilterIterator. - * Receives SplFileInfo, returns bool. Combined with hidden directory filtering if enabled. - * @return \RecursiveIteratorIterator + * @param \Closure|string|null $customFilter Optional filter. If string will be used as regex for + * filtering using `RegexIterator` (applied after iteration), if callable will be used with + * `RecursiveCallbackFilterIterator` (applied during iteration). Combined with hidden directory + * filtering if enabled. + * @return \RecursiveIteratorIterator|\Iterator */ public function createRecursiveIterator( string $path, ?int $flags = null, int $mode = RecursiveIteratorIterator::CHILD_FIRST, bool $includeHiddenDirs = false, - ?Closure $customFilter = null, - ): RecursiveIteratorIterator { + Closure|string|null $customFilter = null, + ): Iterator { $flags ??= FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS; $directory = new RecursiveDirectoryIterator($path, $flags); - // Apply filtering if needed - if (!$includeHiddenDirs || $customFilter !== null) { - $filterCallback = function (SplFileInfo $current) use ($includeHiddenDirs, $customFilter): bool { + // Separate callback filters from regex filters + $callbackFilter = $customFilter instanceof Closure ? $customFilter : null; + $regexFilter = is_string($customFilter) ? $customFilter : null; + + // Apply callback filtering during iteration if needed + if (!$includeHiddenDirs || $callbackFilter !== null) { + $filterCallback = function (SplFileInfo $current) use ($includeHiddenDirs, $callbackFilter): bool { // Skip hidden directories if not included if (!$includeHiddenDirs && str_starts_with($current->getFilename(), '.') && $current->isDir()) { return false; } - // Apply custom filter if provided - if ($customFilter !== null) { - return $customFilter($current); + // Apply custom callback filter if provided + if ($callbackFilter !== null) { + return $callbackFilter($current); } return true; @@ -153,23 +194,14 @@ public function createRecursiveIterator( $directory = new RecursiveCallbackFilterIterator($directory, $filterCallback); } - return new RecursiveIteratorIterator($directory, $mode); - } + $iterator = new RecursiveIteratorIterator($directory, $mode); - /** - * 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); + // Apply regex filter after iteration if provided + if ($regexFilter !== null) { + return new RegexIterator($iterator, $regexFilter); } - return new CallbackFilterIterator($iterator, $filter); + return $iterator; } /** diff --git a/tests/TestCase/Utility/FilesystemTest.php b/tests/TestCase/Utility/FilesystemTest.php index dd649af9ec5..f1cf2d6de49 100644 --- a/tests/TestCase/Utility/FilesystemTest.php +++ b/tests/TestCase/Utility/FilesystemTest.php @@ -273,4 +273,53 @@ public function testCreateRecursiveIteratorAllowsHiddenDirs(): void $this->assertContains('.hidden', $files); $this->assertContains('secret.php', $files); } + + public function testCreateIterator(): void + { + $structure = [ + 'file1.php' => 'content', + 'file2.txt' => 'content', + 'subdir' => [ + 'file3.php' => 'should not see this (non-recursive)', + ], + ]; + vfsStream::create($structure); + + $iterator = $this->fs->createIterator($this->vfsPath); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + // Should only see top-level files, not subdirectories' contents + $this->assertContains('file1.php', $files); + $this->assertContains('file2.txt', $files); + $this->assertContains('subdir', $files); + $this->assertNotContains('file3.php', $files); + } + + public function testCreateIteratorWithCustomFilter(): void + { + $structure = [ + 'file1.php' => 'content', + 'file2.txt' => 'content', + 'file3.php' => 'content', + 'subdir' => [], + ]; + vfsStream::create($structure); + + $filter = fn($file) => $file->isFile() && $file->getExtension() === 'php'; + $iterator = $this->fs->createIterator($this->vfsPath, customFilter: $filter); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('file1.php', $files); + $this->assertContains('file3.php', $files); + $this->assertNotContains('file2.txt', $files); + $this->assertNotContains('subdir', $files); + } } From 8198b78acb2186daa93007ef5edc96bd810a21a3 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 20:08:11 +0100 Subject: [PATCH 009/100] Add support for non-recursive iteration --- src/Filesystem/Finder.php | 171 +++++++++++++++++++---- tests/TestCase/Filesystem/FinderTest.php | 123 ++++++++++++++++ 2 files changed, 264 insertions(+), 30 deletions(-) diff --git a/src/Filesystem/Finder.php b/src/Filesystem/Finder.php index b26b85c2e1f..623d54a691b 100644 --- a/src/Filesystem/Finder.php +++ b/src/Filesystem/Finder.php @@ -18,6 +18,7 @@ use Cake\Utility\Filesystem as FilesystemUtil; use Closure; +use Generator; use Iterator; use RecursiveIteratorIterator; use SplFileInfo; @@ -104,6 +105,13 @@ class Finder */ protected bool $ignoreHiddenFiles = true; + /** + * Whether to search recursively + * + * @var bool + */ + protected bool $recursive = true; + /** * The internal filesystem utility * @@ -238,6 +246,18 @@ public function ignoreHiddenFiles(bool $ignore = true) 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. * @@ -246,43 +266,107 @@ public function ignoreHiddenFiles(bool $ignore = true) public function files(): Iterator { foreach ($this->paths as $path) { - $normalizedBasePath = Path::normalize($path); - - $iterator = $this->filesystem->createRecursiveIterator( - $path, - mode: RecursiveIteratorIterator::LEAVES_ONLY, - includeHiddenDirs: !$this->ignoreHiddenFiles, - customFilter: $this->buildFilter(), - ); - - foreach ($iterator as $file) { - /** @var \SplFileInfo $file */ - if (!$file->isFile()) { - continue; - } + if (!$this->recursive || $this->isDepthZero()) { + yield from $this->iterateNonRecursive($path); + } else { + yield from $this->iterateRecursive($path); + } + } + } - if ($this->ignoreHiddenFiles && str_starts_with($file->getFilename(), '.')) { - continue; - } + /** + * Check if depth is limited to zero (top-level only). + * + * @return bool + */ + protected function isDepthZero(): bool + { + foreach ($this->depths as $condition) { + if (trim($condition) === '== 0') { + return true; + } + } - if (!$this->matchesNamePatterns($file)) { - continue; - } + return false; + } - if (!$this->matchesPathPatterns($file)) { - continue; - } + /** + * Iterate recursively through a directory. + * + * @param string $path The directory path + * @return \Generator<\SplFileInfo> + */ + protected function iterateRecursive(string $path): Generator + { + $normalizedBasePath = Path::normalize($path); + + $iterator = $this->filesystem->createRecursiveIterator( + $path, + mode: RecursiveIteratorIterator::LEAVES_ONLY, + includeHiddenDirs: !$this->ignoreHiddenFiles, + customFilter: $this->buildFilter(), + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile()) { + continue; + } - if (!$this->matchesDepth($file, $normalizedBasePath)) { - continue; - } + if ($this->ignoreHiddenFiles && str_starts_with($file->getFilename(), '.')) { + continue; + } - if (!$this->matchesGlobPatterns($file, $normalizedBasePath)) { - continue; - } + if (!$this->matchesNamePatterns($file)) { + continue; + } - yield $file; + if (!$this->matchesPathPatterns($file)) { + continue; } + + if (!$this->matchesDepth($file, $normalizedBasePath)) { + continue; + } + + if (!$this->matchesGlobPatterns($file, $normalizedBasePath)) { + continue; + } + + yield $file; + } + } + + /** + * Iterate non-recursively through a directory. + * + * @param string $path The directory path + * @return \Generator<\SplFileInfo> + */ + protected function iterateNonRecursive(string $path): Generator + { + $iterator = $this->filesystem->createIterator( + $path, + customFilter: $this->buildNonRecursiveFilter(), + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile()) { + continue; + } + + if ($this->ignoreHiddenFiles && str_starts_with($file->getFilename(), '.')) { + continue; + } + + if (!$this->matchesNamePatterns($file)) { + continue; + } + + // Skip path(), notPath(), depth(), pattern() checks - not applicable in non-recursive mode + + yield $file; } } @@ -316,6 +400,33 @@ protected function buildFilter(): ?Closure }; } + /** + * Build a filter callback for the non-recursive iterator. + * + * @return \Closure|null + */ + protected function buildNonRecursiveFilter(): ?Closure + { + if ($this->exclude === []) { + return null; + } + + return function (SplFileInfo $file): bool { + if (!$file->isDir()) { + return true; + } + + $filename = $file->getFilename(); + foreach ($this->exclude as $excluded) { + if ($filename === $excluded) { + return false; + } + } + + return true; + }; + } + /** * Check if file matches name patterns. * diff --git a/tests/TestCase/Filesystem/FinderTest.php b/tests/TestCase/Filesystem/FinderTest.php index 527a34bd718..58c31724e3b 100644 --- a/tests/TestCase/Filesystem/FinderTest.php +++ b/tests/TestCase/Filesystem/FinderTest.php @@ -708,4 +708,127 @@ public function testNameWithNotName(): void $this->assertNotContains('UsersTable.php', $filenames); $this->assertCount(2, $filenames); } + + public function testRecursiveFalse(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->recursive(false) + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + // Should only find files at top level of src/ + // src/ directory itself has no files, only subdirectories + $this->assertCount(0, $filenames); + } + + public function testNonRecursiveWithFiles(): void + { + // Create structure with files at top level + vfsStream::setup('test', null, [ + 'top.php' => ' ' [ + 'deep.php' => 'in(vfsStream::url('test')) + ->recursive(false) + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertCount(2, $filenames); + $this->assertContains('top.php', $filenames); + $this->assertContains('another.php', $filenames); + $this->assertNotContains('deep.php', $filenames); + } + + public function testNonRecursiveWithNameFilter(): void + { + vfsStream::setup('test', null, [ + 'match.php' => ' 'text', + 'another.php' => 'in(vfsStream::url('test')) + ->recursive(false) + ->name('*.php') + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertCount(2, $filenames); + $this->assertContains('match.php', $filenames); + $this->assertContains('another.php', $filenames); + $this->assertNotContains('ignore.txt', $filenames); + } + + public function testNonRecursiveIgnoresHiddenFiles(): void + { + vfsStream::setup('test', null, [ + 'visible.txt' => 'content', + '.hidden' => 'hidden content', + ]); + + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('test')) + ->recursive(false) + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertCount(1, $filenames); + $this->assertContains('visible.txt', $filenames); + $this->assertNotContains('.hidden', $filenames); + } + + public function testNonRecursiveMultipleDirectories(): void + { + vfsStream::setup('test', null, [ + 'dir1' => [ + 'a.txt' => 'content', + ], + 'dir2' => [ + 'b.txt' => 'content', + ], + ]); + + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('test/dir1')) + ->in(vfsStream::url('test/dir2')) + ->recursive(false) + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertCount(2, $filenames); + $this->assertContains('a.txt', $filenames); + $this->assertContains('b.txt', $filenames); + } } From 38674c513d81084b4d9fa69d6f83096c0b2a1566 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 20:26:56 +0100 Subject: [PATCH 010/100] Refactor I18nExtractCommanda and CommandScanner to use new Finder class --- src/Command/I18nExtractCommand.php | 21 +++++++++++++-------- src/Console/CommandScanner.php | 9 ++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/Command/I18nExtractCommand.php b/src/Command/I18nExtractCommand.php index 31a66abd952..9afce2ba551 100644 --- a/src/Command/I18nExtractCommand.php +++ b/src/Command/I18nExtractCommand.php @@ -24,6 +24,7 @@ use Cake\Core\Configure; use Cake\Core\Exception\CakeException; use Cake\Core\Plugin; +use Cake\Filesystem\Finder; use Cake\Utility\Filesystem; 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/Console/CommandScanner.php b/src/Console/CommandScanner.php index 3dd77883830..7d2c1583e55 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\Filesystem\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) { From 6f1581d706cc68035116bda66b9a298df87ed40b Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 7 Jan 2026 22:14:20 +0100 Subject: [PATCH 011/100] Add test to verify excluding works on nested directories --- tests/TestCase/Filesystem/FinderTest.php | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/TestCase/Filesystem/FinderTest.php b/tests/TestCase/Filesystem/FinderTest.php index 58c31724e3b..b1ac5e0aa5a 100644 --- a/tests/TestCase/Filesystem/FinderTest.php +++ b/tests/TestCase/Filesystem/FinderTest.php @@ -831,4 +831,47 @@ public function testNonRecursiveMultipleDirectories(): void $this->assertContains('a.txt', $filenames); $this->assertContains('b.txt', $filenames); } + + public function testExcludeNestedDirectory(): void + { + // Create structure with nested directories to verify early pruning + $structure = [ + 'project' => [ + 'src' => [ + 'file1.php' => ' [ + 'package1' => [ + 'file2.php' => ' [ + 'deep.php' => ' [ + 'file3.php' => 'in(vfsStream::url('exclude_test/project')) + ->exclude('vendor') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + // Should only find file1.php, not anything in vendor/ + $this->assertCount(1, $paths); + $this->assertStringContainsString('file1.php', $paths[0]); + $this->assertStringNotContainsString('vendor', implode(',', $paths)); + $this->assertStringNotContainsString('file2.php', implode(',', $paths)); + $this->assertStringNotContainsString('file3.php', implode(',', $paths)); + $this->assertStringNotContainsString('deep.php', implode(',', $paths)); + } } From b4faac7ca4ae372a1e987f965d3840a54fd7337f Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 8 Jan 2026 07:52:17 +0100 Subject: [PATCH 012/100] Refactor Path::join so it doesn't need a redundant Path::normalize call at the end --- src/Filesystem/Path.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Filesystem/Path.php b/src/Filesystem/Path.php index 0611fd2bd44..7c151c60488 100644 --- a/src/Filesystem/Path.php +++ b/src/Filesystem/Path.php @@ -111,13 +111,14 @@ public static function join(string ...$segments): string return ''; } - $result = array_shift($segments); + $result = str_replace('\\', '/', array_shift($segments)); foreach ($segments as $segment) { - $result = rtrim($result, '/\\') . '/' . ltrim($segment, '/\\'); + $segment = str_replace('\\', '/', $segment); + $result = rtrim($result, '/') . '/' . ltrim($segment, '/'); } - return static::normalize($result); + return $result; } /** From 189d56c4f6845e63c5daa9e25353493874eccec4 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 8 Jan 2026 10:04:25 +0100 Subject: [PATCH 013/100] Use seperate and argumnts in the depth() method --- src/Filesystem/Finder.php | 48 +++++++++++------------- tests/TestCase/Filesystem/FinderTest.php | 10 ++--- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/Filesystem/Finder.php b/src/Filesystem/Finder.php index 623d54a691b..426cf06e000 100644 --- a/src/Filesystem/Finder.php +++ b/src/Filesystem/Finder.php @@ -94,7 +94,7 @@ class Finder /** * Depth conditions * - * @var array + * @var array */ protected array $depths = []; @@ -223,12 +223,13 @@ public function pattern(string $pattern) /** * Add a depth condition. * - * @param string $condition Depth condition (e.g., '== 0', '< 3') + * @param int $level The depth level (0 = top-level directory) + * @param string $operator The comparison operator (default: '==') * @return $this */ - public function depth(string $condition) + public function depth(int $level, string $operator = '==') { - $this->depths[] = $condition; + $this->depths[] = [$operator, $level]; return $this; } @@ -246,7 +247,7 @@ public function ignoreHiddenFiles(bool $ignore = true) return $this; } - /** * Set whether to search recursively into subdirectories. + /** Set whether to search recursively into subdirectories. * * @param bool $recursive Whether to search recursively (default: true) * @return $this @@ -282,7 +283,7 @@ public function files(): Iterator protected function isDepthZero(): bool { foreach ($this->depths as $condition) { - if (trim($condition) === '== 0') { + if ($condition === ['==', 0]) { return true; } } @@ -526,8 +527,8 @@ protected function matchesDepth(SplFileInfo $file, string $normalizedBasePath): $depth = $relativePath === '' ? 0 : count(explode('/', $relativePath)); - foreach ($this->depths as $condition) { - if (!$this->evaluateDepthCondition($depth, $condition)) { + foreach ($this->depths as [$operator, $level]) { + if (!$this->evaluateDepthCondition($depth, $operator, $level)) { return false; } } @@ -539,27 +540,20 @@ protected function matchesDepth(SplFileInfo $file, string $normalizedBasePath): * Evaluate a depth condition. * * @param int $depth The actual depth - * @param string $condition The condition to evaluate (e.g., '== 0', '< 3') + * @param string $operator The comparison operator + * @param int $level The target depth level * @return bool */ - protected function evaluateDepthCondition(int $depth, string $condition): bool + protected function evaluateDepthCondition(int $depth, string $operator, int $level): bool { - $condition = trim($condition); - - if (preg_match('/^(==|!=|<|>|<=|>=)\s*(\d+)$/', $condition, $matches)) { - $operator = $matches[1]; - $value = (int)$matches[2]; - - return match ($operator) { - '==' => $depth === $value, - '!=' => $depth !== $value, - '<' => $depth < $value, - '>' => $depth > $value, - '<=' => $depth <= $value, - default => $depth >= $value, - }; - } - - return true; + return match ($operator) { + '==' => $depth === $level, + '!=' => $depth !== $level, + '<' => $depth < $level, + '>' => $depth > $level, + '<=' => $depth <= $level, + '>=' => $depth >= $level, + default => true, + }; } } diff --git a/tests/TestCase/Filesystem/FinderTest.php b/tests/TestCase/Filesystem/FinderTest.php index b1ac5e0aa5a..81ae412b1a2 100644 --- a/tests/TestCase/Filesystem/FinderTest.php +++ b/tests/TestCase/Filesystem/FinderTest.php @@ -156,7 +156,7 @@ public function testDepth(): void $finder = new Finder(); $files = $finder ->in(vfsStream::url('root/src')) - ->depth('== 0') + ->depth(0) ->files(); $count = 0; @@ -170,7 +170,7 @@ public function testDepth(): void $finder = new Finder(); $files = $finder ->in(vfsStream::url('root/src')) - ->depth('== 1') + ->depth(1) ->files(); $paths = []; @@ -257,7 +257,7 @@ public function testChaining(): void ->in(vfsStream::url('root/src')) ->name('*.php') ->exclude('View') - ->depth('< 3'); + ->depth(3, '<'); $this->assertInstanceOf(Finder::class, $result); } @@ -480,7 +480,7 @@ public function testPatternWithDepth(): void $files = $finder ->in(vfsStream::url('root/src')) ->pattern('**/*.php') - ->depth('== 1') + ->depth(1) ->files(); $filenames = []; @@ -611,7 +611,7 @@ public function testDepthZero(): void $finder = new Finder(); $files = $finder ->in(vfsStream::url('root/webroot')) - ->depth('== 0') + ->depth(0) ->files(); $filenames = []; From a781dfef708055bb1e340b01d303e65f1258c7e0 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 8 Jan 2026 10:36:53 +0100 Subject: [PATCH 014/100] Add support for finding directories and Introduce enums for depth operators --- src/Filesystem/Enum/DepthOperator.php | 55 +++++++++++ src/Filesystem/Enum/FinderMode.php | 40 ++++++++ src/Filesystem/Finder.php | 114 ++++++++++++++++++---- tests/TestCase/Filesystem/FinderTest.php | 118 ++++++++++++++++++++++- 4 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 src/Filesystem/Enum/DepthOperator.php create mode 100644 src/Filesystem/Enum/FinderMode.php diff --git a/src/Filesystem/Enum/DepthOperator.php b/src/Filesystem/Enum/DepthOperator.php new file mode 100644 index 00000000000..fa210c9795b --- /dev/null +++ b/src/Filesystem/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/Filesystem/Enum/FinderMode.php b/src/Filesystem/Enum/FinderMode.php new file mode 100644 index 00000000000..e8d039a4f83 --- /dev/null +++ b/src/Filesystem/Enum/FinderMode.php @@ -0,0 +1,40 @@ +in('src') @@ -38,6 +41,17 @@ * 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 @@ -94,7 +108,7 @@ class Finder /** * Depth conditions * - * @var array + * @var array */ protected array $depths = []; @@ -112,6 +126,13 @@ class Finder */ protected bool $recursive = true; + /** + * The iteration mode (files, directories, or all) + * + * @var \Cake\Filesystem\Enum\FinderMode|null + */ + protected ?FinderMode $mode = null; + /** * The internal filesystem utility * @@ -224,10 +245,10 @@ public function pattern(string $pattern) * Add a depth condition. * * @param int $level The depth level (0 = top-level directory) - * @param string $operator The comparison operator (default: '==') + * @param \Cake\Filesystem\Enum\DepthOperator $operator The comparison operator (default: EQUAL) * @return $this */ - public function depth(int $level, string $operator = '==') + public function depth(int $level, DepthOperator $operator = DepthOperator::EQUAL) { $this->depths[] = [$operator, $level]; @@ -265,6 +286,42 @@ public function recursive(bool $recursive = true) * @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 \Generator<\SplFileInfo> + */ + protected function iterate(): Generator { foreach ($this->paths as $path) { if (!$this->recursive || $this->isDepthZero()) { @@ -275,6 +332,22 @@ public function files(): Iterator } } + /** + * Check if file/directory matches the mode filter. + * + * @param \SplFileInfo $file The file or directory to check + * @return bool + */ + protected function matchesMode(SplFileInfo $file): bool + { + return match ($this->mode) { + FinderMode::FILES => $file->isFile(), + FinderMode::DIRECTORIES => $file->isDir(), + FinderMode::ALL => true, + null => true, + }; + } + /** * Check if depth is limited to zero (top-level only). * @@ -283,7 +356,7 @@ public function files(): Iterator protected function isDepthZero(): bool { foreach ($this->depths as $condition) { - if ($condition === ['==', 0]) { + if ($condition === [DepthOperator::EQUAL, 0]) { return true; } } @@ -301,16 +374,22 @@ protected function iterateRecursive(string $path): Generator { $normalizedBasePath = Path::normalize($path); + // 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 = $this->filesystem->createRecursiveIterator( $path, - mode: RecursiveIteratorIterator::LEAVES_ONLY, + mode: $iteratorMode, includeHiddenDirs: !$this->ignoreHiddenFiles, customFilter: $this->buildFilter(), ); foreach ($iterator as $file) { /** @var \SplFileInfo $file */ - if (!$file->isFile()) { + if (!$this->matchesMode($file)) { continue; } @@ -353,7 +432,7 @@ protected function iterateNonRecursive(string $path): Generator foreach ($iterator as $file) { /** @var \SplFileInfo $file */ - if (!$file->isFile()) { + if (!$this->matchesMode($file)) { continue; } @@ -365,8 +444,6 @@ protected function iterateNonRecursive(string $path): Generator continue; } - // Skip path(), notPath(), depth(), pattern() checks - not applicable in non-recursive mode - yield $file; } } @@ -540,20 +617,19 @@ protected function matchesDepth(SplFileInfo $file, string $normalizedBasePath): * Evaluate a depth condition. * * @param int $depth The actual depth - * @param string $operator The comparison operator + * @param \Cake\Filesystem\Enum\DepthOperator $operator The comparison operator * @param int $level The target depth level * @return bool */ - protected function evaluateDepthCondition(int $depth, string $operator, int $level): bool + protected function evaluateDepthCondition(int $depth, DepthOperator $operator, int $level): bool { return match ($operator) { - '==' => $depth === $level, - '!=' => $depth !== $level, - '<' => $depth < $level, - '>' => $depth > $level, - '<=' => $depth <= $level, - '>=' => $depth >= $level, - default => true, + DepthOperator::EQUAL => $depth === $level, + DepthOperator::NOT_EQUAL => $depth !== $level, + DepthOperator::LESS_THAN => $depth < $level, + DepthOperator::GREATER_THAN => $depth > $level, + DepthOperator::LESS_THAN_OR_EQUAL => $depth <= $level, + DepthOperator::GREATER_THAN_OR_EQUAL => $depth >= $level, }; } } diff --git a/tests/TestCase/Filesystem/FinderTest.php b/tests/TestCase/Filesystem/FinderTest.php index 81ae412b1a2..b1be2c03f85 100644 --- a/tests/TestCase/Filesystem/FinderTest.php +++ b/tests/TestCase/Filesystem/FinderTest.php @@ -16,6 +16,7 @@ */ namespace Cake\Test\TestCase\Filesystem; +use Cake\Filesystem\Enum\DepthOperator; use Cake\Filesystem\Finder; use Cake\TestSuite\TestCase; use Iterator; @@ -156,7 +157,7 @@ public function testDepth(): void $finder = new Finder(); $files = $finder ->in(vfsStream::url('root/src')) - ->depth(0) + ->depth(3, DepthOperator::LESS_THAN) ->files(); $count = 0; @@ -164,8 +165,8 @@ public function testDepth(): void $count++; } - // No files at depth 0 in src directory - $this->assertEquals(0, $count); + // Should find files at depth 0, 1, and 2 + $this->assertEquals(5, $count); $finder = new Finder(); $files = $finder @@ -257,7 +258,7 @@ public function testChaining(): void ->in(vfsStream::url('root/src')) ->name('*.php') ->exclude('View') - ->depth(3, '<'); + ->depth(3, DepthOperator::LESS_THAN); $this->assertInstanceOf(Finder::class, $result); } @@ -874,4 +875,113 @@ public function testExcludeNestedDirectory(): void $this->assertStringNotContainsString('file3.php', implode(',', $paths)); $this->assertStringNotContainsString('deep.php', implode(',', $paths)); } + + public function testDirectories(): void + { + $finder = new Finder(); + $directories = $finder->in(vfsStream::url('root/src'))->directories(); + + $this->assertInstanceOf(Iterator::class, $directories); + + $paths = []; + foreach ($directories as $dir) { + $paths[] = $dir->getFilename(); + } + + // Should find: Controller, Model, View, Entity, Table (all directories) + $this->assertContains('Controller', $paths); + $this->assertContains('Model', $paths); + $this->assertContains('View', $paths); + $this->assertContains('Entity', $paths); + $this->assertContains('Table', $paths); + + // Should not contain any files + $this->assertNotContains('AppController.php', $paths); + $this->assertNotContains('User.php', $paths); + } + + public function testDirectoriesNonRecursive(): void + { + $finder = new Finder(); + $directories = $finder + ->in(vfsStream::url('root/src')) + ->recursive(false) + ->directories(); + + $paths = []; + foreach ($directories as $dir) { + $paths[] = $dir->getFilename(); + } + + // Should only find top-level directories: Controller, Model, View + $this->assertCount(3, $paths); + $this->assertContains('Controller', $paths); + $this->assertContains('Model', $paths); + $this->assertContains('View', $paths); + + // Should not find nested directories + $this->assertNotContains('Entity', $paths); + $this->assertNotContains('Table', $paths); + } + + public function testDirectoriesWithExclude(): void + { + $finder = new Finder(); + $directories = $finder + ->in(vfsStream::url('root/src')) + ->exclude('Model') + ->directories(); + + $paths = []; + foreach ($directories as $dir) { + $paths[] = $dir->getFilename(); + } + + $this->assertContains('Controller', $paths); + $this->assertContains('View', $paths); + + // Model and its subdirectories should be excluded + $this->assertNotContains('Model', $paths); + $this->assertNotContains('Entity', $paths); + $this->assertNotContains('Table', $paths); + } + + public function testDirectoriesWithDepth(): void + { + $finder = new Finder(); + $directories = $finder + ->in(vfsStream::url('root/src')) + ->depth(0) + ->directories(); + + $paths = []; + foreach ($directories as $dir) { + $paths[] = $dir->getFilename(); + } + + // Only top-level directories + $this->assertCount(3, $paths); + $this->assertContains('Controller', $paths); + $this->assertContains('Model', $paths); + $this->assertContains('View', $paths); + } + + public function testDirectoriesWithNamePattern(): void + { + $finder = new Finder(); + $directories = $finder + ->in(vfsStream::url('root/tests')) + ->name('*Case') + ->recursive(false) + ->directories(); + + $paths = []; + foreach ($directories as $dir) { + $paths[] = $dir->getFilename(); + } + + // Should match TestCase directory under tests/ + $this->assertCount(1, $paths); + $this->assertContains('TestCase', $paths); + } } From ed753d3b8973a72f74045db690a54e640d376778 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 8 Jan 2026 14:12:05 +0100 Subject: [PATCH 015/100] Refactor and optimize Path::join as drop-in replacement for pathCombine() --- src/Filesystem/Path.php | 74 ++++++++++++++++++++------ tests/TestCase/Filesystem/PathTest.php | 50 ++++++++++++++++- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/src/Filesystem/Path.php b/src/Filesystem/Path.php index 7c151c60488..f412a34f36e 100644 --- a/src/Filesystem/Path.php +++ b/src/Filesystem/Path.php @@ -81,14 +81,9 @@ public static function makeRelative(string $path, string $from): string } } - // Build relative path - $relativeParts = []; - - // Add .. for each directory we need to go up + // Build relative path - add .. for each directory we need to go up $upCount = count($fromParts) - $commonLength; - for ($i = 0; $i < $upCount; $i++) { - $relativeParts[] = '..'; - } + $relativeParts = $upCount > 0 ? array_fill(0, $upCount, '..') : []; // Add remaining path segments $relativeParts = array_merge( @@ -100,25 +95,70 @@ public static function makeRelative(string $path, string $from): string } /** - * Joins path segments together using the correct directory separator. + * Joins path segments together. * - * @param string ...$segments Path segments to join + * Preserves existing directory separators (/ or \) from the input segments. + * Use Path::normalize() if you need to convert all separators to forward slashes. + * + * @param string|bool|null ...$segments Path segments to join. The last argument + * can be a bool|null to control trailing slash handling: + * - If true, ensures a trailing forward-slash is added if one doesn't exist + * - If false, ensures any trailing slash is removed + * - If null, ignores trailing slashes (leaves as-is) * @return string The joined path */ - public static function join(string ...$segments): string + public static function join(string|bool|null ...$segments): string { - if ($segments === []) { - return ''; + $isSlash = fn(string $char): bool => $char === '/' || $char === '\\'; + + // Extract trailing parameter if last argument is bool or null (not string) + $trailing = null; + $lastIdx = count($segments) - 1; + if ($lastIdx >= 0) { + $last = $segments[$lastIdx]; + // If last is bool, or null with preceding string, it's a trailing parameter + if (is_bool($last) || ($last === null && ($lastIdx === 0 || is_string($segments[$lastIdx - 1])))) { + $trailing = array_pop($segments); + } } - $result = str_replace('\\', '/', array_shift($segments)); + $numParts = count($segments); + if ($numParts === 0) { + return $trailing === true ? '/' : ''; + } + + $path = (string)$segments[0]; + for ($i = 1; $i < $numParts; ++$i) { + $part = $segments[$i]; + if ($part === '' || $part === null) { + continue; + } + $part = (string)$part; + + $pathEndsWithSlash = $path !== '' && $isSlash($path[-1]); + $partStartsWithSlash = $isSlash($part[0]); - foreach ($segments as $segment) { - $segment = str_replace('\\', '/', $segment); - $result = rtrim($result, '/') . '/' . ltrim($segment, '/'); + if ($pathEndsWithSlash && $partStartsWithSlash) { + $path .= substr($part, 1); + } elseif ($pathEndsWithSlash || $partStartsWithSlash) { + $path .= $part; + } else { + $path .= '/' . $part; + } + } + + // Handle trailing slash + if ($trailing === true) { + if ($path === '' || !$isSlash($path[-1])) { + $path .= '/'; + } + } elseif ($trailing === false) { + if ($path !== '' && $isSlash($path[-1])) { + $path = substr($path, 0, -1); + } } - return $result; + return $path; } /** diff --git a/tests/TestCase/Filesystem/PathTest.php b/tests/TestCase/Filesystem/PathTest.php index 516f329d4df..a308748248a 100644 --- a/tests/TestCase/Filesystem/PathTest.php +++ b/tests/TestCase/Filesystem/PathTest.php @@ -55,13 +55,59 @@ public function testMakeRelative(): void public function testJoin(): void { + // Basic cases $this->assertSame('path/to/file', Path::join('path', 'to', 'file')); $this->assertSame('/absolute/path/file', Path::join('/absolute', 'path', 'file')); - $this->assertSame('path/to/file', Path::join('path/', '/to/', '/file')); - $this->assertSame('path/to/file', Path::join('path\\', '\\to\\', '\\file')); $this->assertSame('', Path::join()); $this->assertSame('path', Path::join('path')); $this->assertSame('path/file', Path::join('path', '', 'file')); + + // Slash combinations + $this->assertSame('path/to/file', Path::join('path/', 'to', 'file')); + $this->assertSame('path/to/file', Path::join('path', 'to/', 'file')); + $this->assertSame('path/to/file', Path::join('path/', 'to/', 'file')); + $this->assertSame('path/to/file', Path::join('path/', '/to/', '/file')); + + // Absolute paths + $this->assertSame('/path/to/file', Path::join('/', 'path', 'to', 'file')); + $this->assertSame('/path/to/file', Path::join('/', '/path', 'to', 'file')); + + // Natural trailing slashes + $this->assertSame('/', Path::join('/', '/')); + $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file/')); + $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file', '/')); + + // Windows-style backslashes + $this->assertSame('path\\to\\file', Path::join('path\\', '\\to\\', '\\file')); + $this->assertSame('/path/to\\file', Path::join('/', '\\path', 'to', '\\file')); + } + + public function testJoinWithTrailing(): void + { + // Test empty cases with trailing + $this->assertSame('', Path::join(false)); + $this->assertSame('/', Path::join(true)); + $this->assertSame('/', Path::join('/', true)); + + // Test adding trailing slash (true) + $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file', true)); + $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file/', '/', true)); + + // Test removing trailing slash (false) + $this->assertSame('', Path::join('/', false)); + $this->assertSame('/path/to/file', Path::join('/path', 'to', 'file/', false)); + $this->assertSame('/path/to/file', Path::join('/path', 'to', 'file/', '/', false)); + + // Test null (ignore trailing slashes) + $this->assertSame('', Path::join(null)); + $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file/', null)); + $this->assertSame('/path/to/file', Path::join('/path', 'to', 'file', null)); + + // Test Windows backslashes with trailing control + $this->assertSame('/path\\to\\file/', Path::join('/', 'path', '\\to\\', 'file', true)); + $this->assertSame('/path\\to\\file\\', Path::join('/', 'path', '\\to\\', 'file', '\\', true)); + $this->assertSame('/path\\to\\file', Path::join('/', 'path', '\\to\\', 'file', false)); + $this->assertSame('/path\\to\\file', Path::join('/', 'path', '\\to\\', 'file', '\\', false)); } public function testMatches(): void From 40d45b3b90edf0eaa28836191a94179606de5375 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 8 Jan 2026 14:35:05 +0100 Subject: [PATCH 016/100] Revert "Refactor and optimize Path::join as drop-in replacement for pathCombine()" This reverts commit ed753d3b8973a72f74045db690a54e640d376778. --- src/Filesystem/Path.php | 74 ++++++-------------------- tests/TestCase/Filesystem/PathTest.php | 50 +---------------- 2 files changed, 19 insertions(+), 105 deletions(-) diff --git a/src/Filesystem/Path.php b/src/Filesystem/Path.php index f412a34f36e..7c151c60488 100644 --- a/src/Filesystem/Path.php +++ b/src/Filesystem/Path.php @@ -81,9 +81,14 @@ public static function makeRelative(string $path, string $from): string } } - // Build relative path - add .. for each directory we need to go up + // Build relative path + $relativeParts = []; + + // Add .. for each directory we need to go up $upCount = count($fromParts) - $commonLength; - $relativeParts = $upCount > 0 ? array_fill(0, $upCount, '..') : []; + for ($i = 0; $i < $upCount; $i++) { + $relativeParts[] = '..'; + } // Add remaining path segments $relativeParts = array_merge( @@ -95,70 +100,25 @@ public static function makeRelative(string $path, string $from): string } /** - * Joins path segments together. + * Joins path segments together using the correct directory separator. * - * Preserves existing directory separators (/ or \) from the input segments. - * Use Path::normalize() if you need to convert all separators to forward slashes. - * - * @param string|bool|null ...$segments Path segments to join. The last argument - * can be a bool|null to control trailing slash handling: - * - If true, ensures a trailing forward-slash is added if one doesn't exist - * - If false, ensures any trailing slash is removed - * - If null, ignores trailing slashes (leaves as-is) + * @param string ...$segments Path segments to join * @return string The joined path */ - public static function join(string|bool|null ...$segments): string + public static function join(string ...$segments): string { - $isSlash = fn(string $char): bool => $char === '/' || $char === '\\'; - - // Extract trailing parameter if last argument is bool or null (not string) - $trailing = null; - $lastIdx = count($segments) - 1; - if ($lastIdx >= 0) { - $last = $segments[$lastIdx]; - // If last is bool, or null with preceding string, it's a trailing parameter - if (is_bool($last) || ($last === null && ($lastIdx === 0 || is_string($segments[$lastIdx - 1])))) { - $trailing = array_pop($segments); - } + if ($segments === []) { + return ''; } - $numParts = count($segments); - if ($numParts === 0) { - return $trailing === true ? '/' : ''; - } - - $path = (string)$segments[0]; - for ($i = 1; $i < $numParts; ++$i) { - $part = $segments[$i]; - if ($part === '' || $part === null) { - continue; - } - $part = (string)$part; - - $pathEndsWithSlash = $path !== '' && $isSlash($path[-1]); - $partStartsWithSlash = $isSlash($part[0]); + $result = str_replace('\\', '/', array_shift($segments)); - if ($pathEndsWithSlash && $partStartsWithSlash) { - $path .= substr($part, 1); - } elseif ($pathEndsWithSlash || $partStartsWithSlash) { - $path .= $part; - } else { - $path .= '/' . $part; - } - } - - // Handle trailing slash - if ($trailing === true) { - if ($path === '' || !$isSlash($path[-1])) { - $path .= '/'; - } - } elseif ($trailing === false) { - if ($path !== '' && $isSlash($path[-1])) { - $path = substr($path, 0, -1); - } + foreach ($segments as $segment) { + $segment = str_replace('\\', '/', $segment); + $result = rtrim($result, '/') . '/' . ltrim($segment, '/'); } - return $path; + return $result; } /** diff --git a/tests/TestCase/Filesystem/PathTest.php b/tests/TestCase/Filesystem/PathTest.php index a308748248a..516f329d4df 100644 --- a/tests/TestCase/Filesystem/PathTest.php +++ b/tests/TestCase/Filesystem/PathTest.php @@ -55,59 +55,13 @@ public function testMakeRelative(): void public function testJoin(): void { - // Basic cases $this->assertSame('path/to/file', Path::join('path', 'to', 'file')); $this->assertSame('/absolute/path/file', Path::join('/absolute', 'path', 'file')); + $this->assertSame('path/to/file', Path::join('path/', '/to/', '/file')); + $this->assertSame('path/to/file', Path::join('path\\', '\\to\\', '\\file')); $this->assertSame('', Path::join()); $this->assertSame('path', Path::join('path')); $this->assertSame('path/file', Path::join('path', '', 'file')); - - // Slash combinations - $this->assertSame('path/to/file', Path::join('path/', 'to', 'file')); - $this->assertSame('path/to/file', Path::join('path', 'to/', 'file')); - $this->assertSame('path/to/file', Path::join('path/', 'to/', 'file')); - $this->assertSame('path/to/file', Path::join('path/', '/to/', '/file')); - - // Absolute paths - $this->assertSame('/path/to/file', Path::join('/', 'path', 'to', 'file')); - $this->assertSame('/path/to/file', Path::join('/', '/path', 'to', 'file')); - - // Natural trailing slashes - $this->assertSame('/', Path::join('/', '/')); - $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file/')); - $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file', '/')); - - // Windows-style backslashes - $this->assertSame('path\\to\\file', Path::join('path\\', '\\to\\', '\\file')); - $this->assertSame('/path/to\\file', Path::join('/', '\\path', 'to', '\\file')); - } - - public function testJoinWithTrailing(): void - { - // Test empty cases with trailing - $this->assertSame('', Path::join(false)); - $this->assertSame('/', Path::join(true)); - $this->assertSame('/', Path::join('/', true)); - - // Test adding trailing slash (true) - $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file', true)); - $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file/', '/', true)); - - // Test removing trailing slash (false) - $this->assertSame('', Path::join('/', false)); - $this->assertSame('/path/to/file', Path::join('/path', 'to', 'file/', false)); - $this->assertSame('/path/to/file', Path::join('/path', 'to', 'file/', '/', false)); - - // Test null (ignore trailing slashes) - $this->assertSame('', Path::join(null)); - $this->assertSame('/path/to/file/', Path::join('/path', 'to', 'file/', null)); - $this->assertSame('/path/to/file', Path::join('/path', 'to', 'file', null)); - - // Test Windows backslashes with trailing control - $this->assertSame('/path\\to\\file/', Path::join('/', 'path', '\\to\\', 'file', true)); - $this->assertSame('/path\\to\\file\\', Path::join('/', 'path', '\\to\\', 'file', '\\', true)); - $this->assertSame('/path\\to\\file', Path::join('/', 'path', '\\to\\', 'file', false)); - $this->assertSame('/path\\to\\file', Path::join('/', 'path', '\\to\\', 'file', '\\', false)); } public function testMatches(): void From d38e2e06a5e50b75e4aed8a587e398e9046443ae Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 9 Jan 2026 23:23:46 -0500 Subject: [PATCH 017/100] Bump version --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index cadb5498c6e..921beff9a72 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -16,4 +16,4 @@ // @license https://opensource.org/licenses/mit-license.php MIT License // +--------------------------------------------------------------------------------------------+ // //////////////////////////////////////////////////////////////////////////////////////////////////// -5.3.0-RC2 \ No newline at end of file +5.4.0-dev From 045f9623e82ae1563bf243e6889f18c776e021ea Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 10 Jan 2026 10:55:13 +0100 Subject: [PATCH 018/100] Refactor: move Filesystem => Utility/Fs --- src/Command/I18nExtractCommand.php | 2 +- src/Console/CommandScanner.php | 2 +- src/{Filesystem => Utility/Fs}/Enum/DepthOperator.php | 2 +- src/{Filesystem => Utility/Fs}/Enum/FinderMode.php | 2 +- src/{Filesystem => Utility/Fs}/Finder.php | 6 +++--- src/{Filesystem => Utility/Fs}/Path.php | 2 +- tests/TestCase/{Filesystem => Utility/Fs}/FinderTest.php | 9 +++++---- tests/TestCase/{Filesystem => Utility/Fs}/PathTest.php | 4 ++-- 8 files changed, 15 insertions(+), 14 deletions(-) rename src/{Filesystem => Utility/Fs}/Enum/DepthOperator.php (97%) rename src/{Filesystem => Utility/Fs}/Enum/FinderMode.php (96%) rename src/{Filesystem => Utility/Fs}/Finder.php (99%) rename src/{Filesystem => Utility/Fs}/Path.php (99%) rename tests/TestCase/{Filesystem => Utility/Fs}/FinderTest.php (99%) rename tests/TestCase/{Filesystem => Utility/Fs}/PathTest.php (97%) diff --git a/src/Command/I18nExtractCommand.php b/src/Command/I18nExtractCommand.php index 9afce2ba551..20e01b713d2 100644 --- a/src/Command/I18nExtractCommand.php +++ b/src/Command/I18nExtractCommand.php @@ -24,7 +24,7 @@ use Cake\Core\Configure; use Cake\Core\Exception\CakeException; use Cake\Core\Plugin; -use Cake\Filesystem\Finder; +use Cake\Utility\Fs\Finder; use Cake\Utility\Filesystem; use Cake\Utility\Inflector; diff --git a/src/Console/CommandScanner.php b/src/Console/CommandScanner.php index 7d2c1583e55..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\Filesystem\Finder; +use Cake\Utility\Fs\Finder; use Cake\Utility\Inflector; use ReflectionClass; diff --git a/src/Filesystem/Enum/DepthOperator.php b/src/Utility/Fs/Enum/DepthOperator.php similarity index 97% rename from src/Filesystem/Enum/DepthOperator.php rename to src/Utility/Fs/Enum/DepthOperator.php index fa210c9795b..ec33e5f5c36 100644 --- a/src/Filesystem/Enum/DepthOperator.php +++ b/src/Utility/Fs/Enum/DepthOperator.php @@ -14,7 +14,7 @@ * @since 5.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Filesystem\Enum; +namespace Cake\Utility\Fs\Enum; /** * Enum for depth comparison operators diff --git a/src/Filesystem/Enum/FinderMode.php b/src/Utility/Fs/Enum/FinderMode.php similarity index 96% rename from src/Filesystem/Enum/FinderMode.php rename to src/Utility/Fs/Enum/FinderMode.php index e8d039a4f83..3a60dfa48f6 100644 --- a/src/Filesystem/Enum/FinderMode.php +++ b/src/Utility/Fs/Enum/FinderMode.php @@ -14,7 +14,7 @@ * @since 5.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Filesystem\Enum; +namespace Cake\Utility\Fs\Enum; /** * Enum for Finder iteration modes diff --git a/src/Filesystem/Finder.php b/src/Utility/Fs/Finder.php similarity index 99% rename from src/Filesystem/Finder.php rename to src/Utility/Fs/Finder.php index eb98320e18a..aa5f3dba379 100644 --- a/src/Filesystem/Finder.php +++ b/src/Utility/Fs/Finder.php @@ -14,11 +14,11 @@ * @since 5.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Filesystem; +namespace Cake\Utility\Fs; -use Cake\Filesystem\Enum\DepthOperator; -use Cake\Filesystem\Enum\FinderMode; use Cake\Utility\Filesystem as FilesystemUtil; +use Cake\Utility\Fs\Enum\DepthOperator; +use Cake\Utility\Fs\Enum\FinderMode; use Closure; use Generator; use Iterator; diff --git a/src/Filesystem/Path.php b/src/Utility/Fs/Path.php similarity index 99% rename from src/Filesystem/Path.php rename to src/Utility/Fs/Path.php index 7c151c60488..3ff6b2ec135 100644 --- a/src/Filesystem/Path.php +++ b/src/Utility/Fs/Path.php @@ -14,7 +14,7 @@ * @since 5.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Filesystem; +namespace Cake\Utility\Fs; /** * Path utility class for cross-platform path manipulation. diff --git a/tests/TestCase/Filesystem/FinderTest.php b/tests/TestCase/Utility/Fs/FinderTest.php similarity index 99% rename from tests/TestCase/Filesystem/FinderTest.php rename to tests/TestCase/Utility/Fs/FinderTest.php index b1be2c03f85..8ebc0bbe860 100644 --- a/tests/TestCase/Filesystem/FinderTest.php +++ b/tests/TestCase/Utility/Fs/FinderTest.php @@ -14,11 +14,11 @@ * @since 5.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\Filesystem; +namespace Cake\Test\TestCase\Utility\Fs; -use Cake\Filesystem\Enum\DepthOperator; -use Cake\Filesystem\Finder; use Cake\TestSuite\TestCase; +use Cake\Utility\Fs\Enum\DepthOperator; +use Cake\Utility\Fs\Finder; use Iterator; use org\bovigo\vfs\vfsStream; @@ -854,7 +854,8 @@ public function testExcludeNestedDirectory(): void ], ], ]; - $root = vfsStream::setup('exclude_test', null, $structure); + + vfsStream::setup('exclude_test', null, $structure); $finder = new Finder(); $files = $finder diff --git a/tests/TestCase/Filesystem/PathTest.php b/tests/TestCase/Utility/Fs/PathTest.php similarity index 97% rename from tests/TestCase/Filesystem/PathTest.php rename to tests/TestCase/Utility/Fs/PathTest.php index 516f329d4df..9bc44262214 100644 --- a/tests/TestCase/Filesystem/PathTest.php +++ b/tests/TestCase/Utility/Fs/PathTest.php @@ -14,10 +14,10 @@ * @since 5.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\Filesystem; +namespace Cake\Test\TestCase\Utility\Fs; -use Cake\Filesystem\Path; use Cake\TestSuite\TestCase; +use Cake\Utility\Fs\Path; /** * Path test case From c5e78c36d5bd74ef0f9c02f819262354b68d8a9e Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 10 Jan 2026 11:55:21 +0100 Subject: [PATCH 019/100] Refactor using iterator chaining --- src/Command/I18nExtractCommand.php | 2 +- src/Utility/Filesystem.php | 124 +----- src/Utility/Fs/Enum/DepthOperator.php | 2 +- src/Utility/Fs/Enum/FinderMode.php | 2 +- src/Utility/Fs/Finder.php | 368 ++++------------ .../Iterator/ContainsPathFilterIterator.php | 56 +++ .../Fs/Iterator/DepthFilterIterator.php | 85 ++++ .../ExcludeDirectoryFilterIterator.php | 79 ++++ .../Fs/Iterator/FileTypeFilterIterator.php | 63 +++ .../Fs/Iterator/FilenameFilterIterator.php | 59 +++ .../Fs/Iterator/GlobFilterIterator.php | 64 +++ .../Fs/Iterator/HiddenFileFilterIterator.php | 49 +++ .../Iterator/MultiplePcreFilterIterator.php | 55 +++ .../NotContainsPathFilterIterator.php | 74 ++++ .../Fs/Iterator/NotFilenameFilterIterator.php | 56 +++ src/Utility/Fs/Path.php | 2 +- tests/TestCase/Utility/FilesystemTest.php | 146 +----- .../Fs/Iterator/FilterIteratorTest.php | 213 +++++++++ .../Fs/Iterator/PatternFilterIteratorTest.php | 416 ++++++++++++++++++ 19 files changed, 1366 insertions(+), 549 deletions(-) create mode 100644 src/Utility/Fs/Iterator/ContainsPathFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/DepthFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/FileTypeFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/FilenameFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/GlobFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/HiddenFileFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php create mode 100644 src/Utility/Fs/Iterator/NotFilenameFilterIterator.php create mode 100644 tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php create mode 100644 tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php diff --git a/src/Command/I18nExtractCommand.php b/src/Command/I18nExtractCommand.php index 20e01b713d2..c7f9511ecd4 100644 --- a/src/Command/I18nExtractCommand.php +++ b/src/Command/I18nExtractCommand.php @@ -24,8 +24,8 @@ use Cake\Core\Configure; use Cake\Core\Exception\CakeException; use Cake\Core\Plugin; -use Cake\Utility\Fs\Finder; use Cake\Utility\Filesystem; +use Cake\Utility\Fs\Finder; use Cake\Utility\Inflector; /** diff --git a/src/Utility/Filesystem.php b/src/Utility/Filesystem.php index 26d2039ff6f..2a614d661f1 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; @@ -55,40 +55,6 @@ class Filesystem */ public function find(string $path, Closure|string|null $filter = null, ?int $flags = null): Iterator { - return $this->createIterator($path, $flags, $filter); - } - - /** - * Create a non-recursive directory iterator with optional filtering. - * - * This is a building block method for creating custom non-recursive file iteration. - * Consumers can wrap the returned iterator with additional filters or process it directly. - * - * Example: - * ```php - * $filesystem = new Filesystem(); - * $iterator = $filesystem->createIterator( - * '/path/to/dir', - * customFilter: fn($file) => $file->getExtension() === 'php' - * ); - * - * foreach ($iterator as $file) { - * echo $file->getPathname() . PHP_EOL; - * } - * ``` - * - * @param string $path Directory path. - * @param int|null $flags Flags for FilesystemIterator::__construct(); - * @param \Closure|string|null $customFilter Optional filter. If string will be used as regex for - * filtering using `RegexIterator`, if callable will be as callback for `CallbackFilterIterator`. - * Receives SplFileInfo, returns bool. - * @return \Iterator - */ - public function createIterator( - string $path, - ?int $flags = null, - Closure|string|null $customFilter = null, - ): Iterator { $flags ??= FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS; @@ -96,15 +62,15 @@ public function createIterator( $directory = new FilesystemIterator($path, $flags); // Apply filter if provided - if ($customFilter === null) { + if ($filter === null) { return $directory; } - if (is_string($customFilter)) { - return new RegexIterator($directory, $customFilter); + if (is_string($filter)) { + return new RegexIterator($directory, $filter); } - return new CallbackFilterIterator($directory, $customFilter); + return new CallbackFilterIterator($directory, $filter); } /** @@ -119,89 +85,27 @@ public function createIterator( */ public function findRecursive(string $path, Closure|string|null $filter = null, ?int $flags = null): Iterator { - return $this->createRecursiveIterator( - $path, - $flags, - RecursiveIteratorIterator::CHILD_FIRST, - includeHiddenDirs: false, - customFilter: $filter, - ); - } - - /** - * Create a recursive directory iterator with optional filtering. - * - * This is a building block method for creating custom recursive file iteration. - * Consumers can wrap the returned iterator with additional filters or process it directly. - * - * Example: - * ```php - * $filesystem = new Filesystem(); - * $iterator = $filesystem->createRecursiveIterator( - * '/path/to/dir', - * mode: RecursiveIteratorIterator::LEAVES_ONLY, - * customFilter: fn($file) => $file->getExtension() === 'php' - * ); - * - * foreach ($iterator as $file) { - * echo $file->getPathname() . PHP_EOL; - * } - * ``` - * - * @param string $path Directory path. - * @param int|null $flags Flags for FilesystemIterator::__construct(); - * @param int<0, 2> $mode RecursiveIteratorIterator mode (LEAVES_ONLY, SELF_FIRST, CHILD_FIRST). - * @param bool $includeHiddenDirs Whether to include hidden directories (default: false). - * @param \Closure|string|null $customFilter Optional filter. If string will be used as regex for - * filtering using `RegexIterator` (applied after iteration), if callable will be used with - * `RecursiveCallbackFilterIterator` (applied during iteration). Combined with hidden directory - * filtering if enabled. - * @return \RecursiveIteratorIterator|\Iterator - */ - public function createRecursiveIterator( - string $path, - ?int $flags = null, - int $mode = RecursiveIteratorIterator::CHILD_FIRST, - bool $includeHiddenDirs = false, - Closure|string|null $customFilter = null, - ): Iterator { $flags ??= FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS; $directory = new RecursiveDirectoryIterator($path, $flags); - // Separate callback filters from regex filters - $callbackFilter = $customFilter instanceof Closure ? $customFilter : null; - $regexFilter = is_string($customFilter) ? $customFilter : null; + // Filter out hidden directories + $filtered = new HiddenFileFilterIterator($directory); - // Apply callback filtering during iteration if needed - if (!$includeHiddenDirs || $callbackFilter !== null) { - $filterCallback = function (SplFileInfo $current) use ($includeHiddenDirs, $callbackFilter): bool { - // Skip hidden directories if not included - if (!$includeHiddenDirs && str_starts_with($current->getFilename(), '.') && $current->isDir()) { - return false; - } + $iterator = new RecursiveIteratorIterator($filtered, RecursiveIteratorIterator::CHILD_FIRST); - // Apply custom callback filter if provided - if ($callbackFilter !== null) { - return $callbackFilter($current); - } - - return true; - }; - - $directory = new RecursiveCallbackFilterIterator($directory, $filterCallback); + // Apply custom filter if provided + if ($filter === null) { + return $iterator; } - $iterator = new RecursiveIteratorIterator($directory, $mode); - - // Apply regex filter after iteration if provided - if ($regexFilter !== null) { - return new RegexIterator($iterator, $regexFilter); + if (is_string($filter)) { + return new RegexIterator($iterator, $filter); } - return $iterator; + return new CallbackFilterIterator($iterator, $filter); } /** diff --git a/src/Utility/Fs/Enum/DepthOperator.php b/src/Utility/Fs/Enum/DepthOperator.php index ec33e5f5c36..7c2addbae82 100644 --- a/src/Utility/Fs/Enum/DepthOperator.php +++ b/src/Utility/Fs/Enum/DepthOperator.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.3.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Enum; diff --git a/src/Utility/Fs/Enum/FinderMode.php b/src/Utility/Fs/Enum/FinderMode.php index 3a60dfa48f6..1fbf5a9b7fc 100644 --- a/src/Utility/Fs/Enum/FinderMode.php +++ b/src/Utility/Fs/Enum/FinderMode.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.3.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Enum; diff --git a/src/Utility/Fs/Finder.php b/src/Utility/Fs/Finder.php index aa5f3dba379..1feaffb86bb 100644 --- a/src/Utility/Fs/Finder.php +++ b/src/Utility/Fs/Finder.php @@ -11,19 +11,28 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.3.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs; -use Cake\Utility\Filesystem as FilesystemUtil; +use AppendIterator; use Cake\Utility\Fs\Enum\DepthOperator; use Cake\Utility\Fs\Enum\FinderMode; -use Closure; -use Generator; +use Cake\Utility\Fs\Iterator\ContainsPathFilterIterator; +use Cake\Utility\Fs\Iterator\DepthFilterIterator; +use Cake\Utility\Fs\Iterator\ExcludeDirectoryFilterIterator; +use Cake\Utility\Fs\Iterator\FilenameFilterIterator; +use Cake\Utility\Fs\Iterator\FileTypeFilterIterator; +use Cake\Utility\Fs\Iterator\GlobFilterIterator; +use Cake\Utility\Fs\Iterator\HiddenFileFilterIterator; +use Cake\Utility\Fs\Iterator\MultiplePcreFilterIterator; +use Cake\Utility\Fs\Iterator\NotContainsPathFilterIterator; +use Cake\Utility\Fs\Iterator\NotFilenameFilterIterator; +use FilesystemIterator; use Iterator; +use RecursiveDirectoryIterator; use RecursiveIteratorIterator; -use SplFileInfo; /** * Finder provides a fluent interface for finding files and directories. @@ -108,7 +117,7 @@ class Finder /** * Depth conditions * - * @var array + * @var array */ protected array $depths = []; @@ -129,25 +138,10 @@ class Finder /** * The iteration mode (files, directories, or all) * - * @var \Cake\Filesystem\Enum\FinderMode|null + * @var \Cake\Utility\Fs\Enum\FinderMode|null */ protected ?FinderMode $mode = null; - /** - * The internal filesystem utility - * - * @var \Cake\Utility\Filesystem - */ - protected FilesystemUtil $filesystem; - - /** - * Constructor - */ - public function __construct() - { - $this->filesystem = new FilesystemUtil(); - } - /** * Add a path to search in. * @@ -245,7 +239,7 @@ public function pattern(string $pattern) * Add a depth condition. * * @param int $level The depth level (0 = top-level directory) - * @param \Cake\Filesystem\Enum\DepthOperator $operator The comparison operator (default: EQUAL) + * @param \Cake\Utility\Fs\Enum\DepthOperator $operator The comparison operator (default: EQUAL) * @return $this */ public function depth(int $level, DepthOperator $operator = DepthOperator::EQUAL) @@ -319,317 +313,103 @@ public function all(): Iterator /** * Iterate over items matching the criteria. * - * @return \Generator<\SplFileInfo> + * @return \Iterator<\SplFileInfo> */ - protected function iterate(): Generator + protected function iterate(): Iterator { - foreach ($this->paths as $path) { - if (!$this->recursive || $this->isDepthZero()) { - yield from $this->iterateNonRecursive($path); - } else { - yield from $this->iterateRecursive($path); - } + // Combine results from all paths + if (count($this->paths) === 1) { + return $this->buildIterator($this->paths[0]); } - } - - /** - * Check if file/directory matches the mode filter. - * - * @param \SplFileInfo $file The file or directory to check - * @return bool - */ - protected function matchesMode(SplFileInfo $file): bool - { - return match ($this->mode) { - FinderMode::FILES => $file->isFile(), - FinderMode::DIRECTORIES => $file->isDir(), - FinderMode::ALL => true, - null => true, - }; - } - /** - * Check if depth is limited to zero (top-level only). - * - * @return bool - */ - protected function isDepthZero(): bool - { - foreach ($this->depths as $condition) { - if ($condition === [DepthOperator::EQUAL, 0]) { - return true; - } + // Multiple paths - use AppendIterator + $append = new AppendIterator(); + foreach ($this->paths as $path) { + $append->append($this->buildIterator($path)); } - return false; + return $append; } /** - * Iterate recursively through a directory. + * Build an iterator chain with all configured filters. * * @param string $path The directory path - * @return \Generator<\SplFileInfo> - */ - protected function iterateRecursive(string $path): Generator - { - $normalizedBasePath = Path::normalize($path); - - // 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 = $this->filesystem->createRecursiveIterator( - $path, - mode: $iteratorMode, - includeHiddenDirs: !$this->ignoreHiddenFiles, - customFilter: $this->buildFilter(), - ); - - foreach ($iterator as $file) { - /** @var \SplFileInfo $file */ - if (!$this->matchesMode($file)) { - continue; - } - - if ($this->ignoreHiddenFiles && str_starts_with($file->getFilename(), '.')) { - continue; - } - - if (!$this->matchesNamePatterns($file)) { - continue; - } - - if (!$this->matchesPathPatterns($file)) { - continue; - } - - if (!$this->matchesDepth($file, $normalizedBasePath)) { - continue; - } - - if (!$this->matchesGlobPatterns($file, $normalizedBasePath)) { - continue; - } - - yield $file; - } - } - - /** - * Iterate non-recursively through a directory. - * - * @param string $path The directory path - * @return \Generator<\SplFileInfo> + * @return \Iterator<\SplFileInfo> */ - protected function iterateNonRecursive(string $path): Generator + protected function buildIterator(string $path): Iterator { - $iterator = $this->filesystem->createIterator( - $path, - customFilter: $this->buildNonRecursiveFilter(), - ); - - foreach ($iterator as $file) { - /** @var \SplFileInfo $file */ - if (!$this->matchesMode($file)) { - continue; - } + $flags = FilesystemIterator::KEY_AS_PATHNAME + | FilesystemIterator::CURRENT_AS_FILEINFO + | FilesystemIterator::SKIP_DOTS; - if ($this->ignoreHiddenFiles && str_starts_with($file->getFilename(), '.')) { - continue; - } + $directory = new RecursiveDirectoryIterator($path, $flags); - if (!$this->matchesNamePatterns($file)) { - continue; - } - - yield $file; + // Apply hidden file filtering + if ($this->ignoreHiddenFiles) { + $directory = new HiddenFileFilterIterator($directory); } - } - /** - * Build a filter callback for the recursive iterator. - * - * @return \Closure|null - */ - protected function buildFilter(): ?Closure - { - if ($this->exclude === [] && $this->notPathPatterns === []) { - return null; + // Apply directory exclusions + if ($this->exclude !== []) { + $directory = new ExcludeDirectoryFilterIterator($directory, $this->exclude); } - return function (SplFileInfo $file): bool { - // Check excluded directories - foreach ($this->exclude as $excluded) { - if ($file->isDir() && str_contains($file->getPathname(), DIRECTORY_SEPARATOR . $excluded)) { - return false; - } - } - - // Check excluded path patterns - foreach ($this->notPathPatterns as $pattern) { - if (str_contains($file->getPathname(), $pattern)) { - return false; - } - } - - return true; - }; - } - - /** - * Build a filter callback for the non-recursive iterator. - * - * @return \Closure|null - */ - protected function buildNonRecursiveFilter(): ?Closure - { - if ($this->exclude === []) { - return null; + // Apply path pattern exclusions during recursion + if ($this->notPathPatterns !== []) { + $directory = new NotContainsPathFilterIterator($directory, $this->notPathPatterns); } - return function (SplFileInfo $file): bool { - if (!$file->isDir()) { - return true; - } - - $filename = $file->getFilename(); - foreach ($this->exclude as $excluded) { - if ($filename === $excluded) { - return false; - } - } - - return true; - }; - } + // 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; - /** - * Check if file matches name patterns. - * - * @param \SplFileInfo $file The file to check - * @return bool - */ - protected function matchesNamePatterns(SplFileInfo $file): bool - { - $filename = $file->getFilename(); + $iterator = new RecursiveIteratorIterator($directory, $iteratorMode); - // Check negative patterns first - foreach ($this->notNames as $pattern) { - if (Path::matches($pattern, $filename)) { - return false; - } + // Apply file type filtering + if ($this->mode !== null && $this->mode !== FinderMode::ALL) { + $iterator = new FileTypeFilterIterator($iterator, $this->mode); } - // If no positive patterns, accept all - if ($this->names === []) { - return true; + // Apply filename filtering + if ($this->names !== []) { + $iterator = new FilenameFilterIterator($iterator, $this->names); } - - // Must match at least one positive pattern - foreach ($this->names as $pattern) { - if (Path::matches($pattern, $filename)) { - return true; - } + if ($this->notNames !== []) { + $iterator = new NotFilenameFilterIterator($iterator, $this->notNames); } - return false; - } - - /** - * Check if file matches path patterns. - * - * @param \SplFileInfo $file The file to check - * @return bool - */ - protected function matchesPathPatterns(SplFileInfo $file): bool - { - // Must match all include patterns + // Apply path pattern inclusions if ($this->pathPatterns !== []) { - $matched = false; + // Check if patterns are regex (start with delimiter like /, #, ~) + $hasRegex = false; foreach ($this->pathPatterns as $pattern) { - if (str_contains($file->getPathname(), $pattern)) { - $matched = true; + if (preg_match('/^[\/#~]/', $pattern)) { + $hasRegex = true; break; } } - if (!$matched) { - return false; - } - } - - return true; - } - /** - * Check if file matches glob patterns. - * - * @param \SplFileInfo $file The file to check - * @param string $normalizedBasePath The normalized base path to calculate relative path from - * @return bool - */ - protected function matchesGlobPatterns(SplFileInfo $file, string $normalizedBasePath): bool - { - if ($this->globPatterns === []) { - return true; + $iterator = $hasRegex + ? new MultiplePcreFilterIterator($iterator, $this->pathPatterns) + : new ContainsPathFilterIterator($iterator, $this->pathPatterns); } - $relativePath = Path::makeRelative($file->getPathname(), $normalizedBasePath); - - foreach ($this->globPatterns as $pattern) { - if (Path::matches($pattern, $relativePath)) { - return true; - } + // Apply depth filtering (handles non-recursive mode when recursive=false) + if (!$this->recursive) { + $iterator = new DepthFilterIterator($iterator, DepthOperator::EQUAL, 0); } - - return false; - } - - /** - * Check if file matches depth conditions. - * - * @param \SplFileInfo $file The file to check - * @param string $normalizedBasePath The normalized base path to calculate depth from - * @return bool - */ - protected function matchesDepth(SplFileInfo $file, string $normalizedBasePath): bool - { - if ($this->depths === []) { - return true; - } - - $filePath = Path::normalize($file->getPath()); - $relativePath = Path::makeRelative($filePath, $normalizedBasePath); - - $depth = $relativePath === '' ? 0 : count(explode('/', $relativePath)); - foreach ($this->depths as [$operator, $level]) { - if (!$this->evaluateDepthCondition($depth, $operator, $level)) { - return false; - } + $iterator = new DepthFilterIterator($iterator, $operator, $level); } - return true; - } + // Apply glob pattern filtering + if ($this->globPatterns !== []) { + return new GlobFilterIterator($iterator, $this->globPatterns, $path); + } - /** - * Evaluate a depth condition. - * - * @param int $depth The actual depth - * @param \Cake\Filesystem\Enum\DepthOperator $operator The comparison operator - * @param int $level The target depth level - * @return bool - */ - protected function evaluateDepthCondition(int $depth, DepthOperator $operator, int $level): bool - { - return match ($operator) { - DepthOperator::EQUAL => $depth === $level, - DepthOperator::NOT_EQUAL => $depth !== $level, - DepthOperator::LESS_THAN => $depth < $level, - DepthOperator::GREATER_THAN => $depth > $level, - DepthOperator::LESS_THAN_OR_EQUAL => $depth <= $level, - DepthOperator::GREATER_THAN_OR_EQUAL => $depth >= $level, - }; + return $iterator; } } diff --git a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php new file mode 100644 index 00000000000..4cc24767e31 --- /dev/null +++ b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php @@ -0,0 +1,56 @@ + $patterns Path patterns to include + */ + public function __construct( + Iterator $iterator, + protected array $patterns, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $path = $this->current()->getPathname(); + + foreach ($this->patterns as $pattern) { + if (str_contains($path, $pattern)) { + return true; + } + } + + return false; + } +} diff --git a/src/Utility/Fs/Iterator/DepthFilterIterator.php b/src/Utility/Fs/Iterator/DepthFilterIterator.php new file mode 100644 index 00000000000..7834ad2309d --- /dev/null +++ b/src/Utility/Fs/Iterator/DepthFilterIterator.php @@ -0,0 +1,85 @@ += value + */ +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 DepthOperator $operator, + protected 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..2733b0b5de6 --- /dev/null +++ b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php @@ -0,0 +1,79 @@ + $excludeDirs Array of directory names to exclude + */ + public function __construct( + RecursiveIterator $iterator, + protected 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..89b3e171d16 --- /dev/null +++ b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php @@ -0,0 +1,63 @@ +mode = $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..cdd379b8bcd --- /dev/null +++ b/src/Utility/Fs/Iterator/FilenameFilterIterator.php @@ -0,0 +1,59 @@ + $iterator The iterator to filter + * @param array $patterns Glob patterns to match against + */ + public function __construct( + Iterator $iterator, + protected array $patterns, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $filename = $this->current()->getFilename(); + + foreach ($this->patterns as $pattern) { + if (Path::matches($pattern, $filename)) { + return true; + } + } + + return false; + } +} diff --git a/src/Utility/Fs/Iterator/GlobFilterIterator.php b/src/Utility/Fs/Iterator/GlobFilterIterator.php new file mode 100644 index 00000000000..b548345f3a4 --- /dev/null +++ b/src/Utility/Fs/Iterator/GlobFilterIterator.php @@ -0,0 +1,64 @@ + $patterns Glob patterns to match + * @param string $basePath Base path to calculate relative paths from + */ + public function __construct( + Iterator $iterator, + protected array $patterns, + protected 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..970aff0f96c --- /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/Iterator/MultiplePcreFilterIterator.php b/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php new file mode 100644 index 00000000000..a4cd6970302 --- /dev/null +++ b/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php @@ -0,0 +1,55 @@ + $iterator The iterator to filter + * @param array $patterns Regular expressions to match against + */ + public function __construct( + Iterator $iterator, + protected array $patterns, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $path = $this->current()->getPathname(); + + foreach ($this->patterns as $pattern) { + if (preg_match($pattern, $path)) { + return true; + } + } + + return false; + } +} diff --git a/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php new file mode 100644 index 00000000000..158bd88c80c --- /dev/null +++ b/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php @@ -0,0 +1,74 @@ + $patterns Path patterns to exclude + */ + public function __construct( + RecursiveIterator $iterator, + protected array $patterns, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $current = $this->current(); + + // Always accept directories to allow traversal + if ($current->isDir()) { + return true; + } + + // For files, check if path contains excluded patterns + $path = $current->getPathname(); + foreach ($this->patterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function getChildren(): self + { + /** @var \RecursiveIterator $inner */ + $inner = $this->getInnerIterator(); + + return new self($inner->getChildren(), $this->patterns); + } +} diff --git a/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php b/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php new file mode 100644 index 00000000000..c330af4e12e --- /dev/null +++ b/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php @@ -0,0 +1,56 @@ + $patterns Glob patterns to exclude + */ + public function __construct( + Iterator $iterator, + protected array $patterns, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $filename = $this->current()->getFilename(); + + foreach ($this->patterns as $pattern) { + if (Path::matches($pattern, $filename)) { + return false; + } + } + + return true; + } +} diff --git a/src/Utility/Fs/Path.php b/src/Utility/Fs/Path.php index 3ff6b2ec135..6e60a2b76cd 100644 --- a/src/Utility/Fs/Path.php +++ b/src/Utility/Fs/Path.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.3.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs; diff --git a/tests/TestCase/Utility/FilesystemTest.php b/tests/TestCase/Utility/FilesystemTest.php index f1cf2d6de49..83fbba836dc 100644 --- a/tests/TestCase/Utility/FilesystemTest.php +++ b/tests/TestCase/Utility/FilesystemTest.php @@ -19,7 +19,6 @@ use Cake\TestSuite\TestCase; use Cake\Utility\Filesystem; use org\bovigo\vfs\vfsStream; -use RecursiveIteratorIterator; /** * Filesystem class @@ -110,120 +109,7 @@ public function testDeleteDirWithLinks(): void $this->assertFalse(file_exists($link)); } - public function testCreateRecursiveIteratorBasic(): void - { - $structure = [ - 'file1.php' => 'content', - 'file2.txt' => 'content', - 'subdir' => [ - 'file3.php' => 'content', - 'file4.txt' => 'content', - ], - ]; - vfsStream::create($structure); - - $iterator = $this->fs->createRecursiveIterator($this->vfsPath); - - $this->assertInstanceOf(RecursiveIteratorIterator::class, $iterator); - - $files = []; - foreach ($iterator as $file) { - $files[] = $file->getFilename(); - } - - $this->assertContains('file1.php', $files); - $this->assertContains('file2.txt', $files); - $this->assertContains('file3.php', $files); - $this->assertContains('file4.txt', $files); - } - - public function testCreateRecursiveIteratorSkipsHiddenDirs(): void - { - $structure = [ - 'visible.php' => 'content', - '.hidden' => [ - 'secret.php' => 'should not see this', - ], - 'subdir' => [ - 'visible2.php' => 'content', - ], - ]; - vfsStream::create($structure); - - $iterator = $this->fs->createRecursiveIterator($this->vfsPath, includeHiddenDirs: false); - - $files = []; - foreach ($iterator as $file) { - $files[] = $file->getFilename(); - } - - $this->assertContains('visible.php', $files); - $this->assertContains('visible2.php', $files); - $this->assertNotContains('secret.php', $files); - $this->assertNotContains('.hidden', $files); - } - - public function testCreateRecursiveIteratorWithCustomFilter(): void - { - $structure = [ - 'file1.php' => 'content', - 'file2.txt' => 'content', - 'subdir' => [ - 'file3.php' => 'content', - 'file4.txt' => 'content', - ], - ]; - vfsStream::create($structure); - - // Filter to only include .php files - // Note: Must allow directories to pass for recursion to work - $filter = fn($file) => $file->isDir() || $file->getExtension() === 'php'; - $iterator = $this->fs->createRecursiveIterator( - $this->vfsPath, - customFilter: $filter, - ); - - $files = []; - foreach ($iterator as $file) { - if ($file->isFile()) { - $files[] = $file->getFilename(); - } - } - - $this->assertContains('file1.php', $files); - $this->assertContains('file3.php', $files); - $this->assertNotContains('file2.txt', $files); - $this->assertNotContains('file4.txt', $files); - } - - public function testCreateRecursiveIteratorWithDifferentModes(): void - { - $structure = [ - 'file1.php' => 'content', - 'subdir' => [ - 'file2.php' => 'content', - ], - ]; - vfsStream::create($structure); - - // Test LEAVES_ONLY mode - $iterator = $this->fs->createRecursiveIterator( - $this->vfsPath, - mode: RecursiveIteratorIterator::LEAVES_ONLY, - ); - - $files = []; - foreach ($iterator as $file) { - if ($file->isFile()) { - $files[] = $file->getFilename(); - } - } - - $this->assertContains('file1.php', $files); - $this->assertContains('file2.php', $files); - } - - public function testFindRecursiveStillWorks(): void + public function testFindRecursive(): void { $structure = [ 'file1.php' => 'content', @@ -252,29 +138,7 @@ public function testFindRecursiveStillWorks(): void $this->assertNotContains('secret.php', $files); } - public function testCreateRecursiveIteratorAllowsHiddenDirs(): void - { - $structure = [ - 'visible.php' => 'content', - '.hidden' => [ - 'secret.php' => 'content in hidden dir', - ], - ]; - vfsStream::create($structure); - - $iterator = $this->fs->createRecursiveIterator($this->vfsPath, includeHiddenDirs: true); - - $files = []; - foreach ($iterator as $file) { - $files[] = $file->getFilename(); - } - - $this->assertContains('visible.php', $files); - $this->assertContains('.hidden', $files); - $this->assertContains('secret.php', $files); - } - - public function testCreateIterator(): void + public function testFind(): void { $structure = [ 'file1.php' => 'content', @@ -285,7 +149,7 @@ public function testCreateIterator(): void ]; vfsStream::create($structure); - $iterator = $this->fs->createIterator($this->vfsPath); + $iterator = $this->fs->find($this->vfsPath); $files = []; foreach ($iterator as $file) { @@ -299,7 +163,7 @@ public function testCreateIterator(): void $this->assertNotContains('file3.php', $files); } - public function testCreateIteratorWithCustomFilter(): void + public function testFindWithCustomFilter(): void { $structure = [ 'file1.php' => 'content', @@ -310,7 +174,7 @@ public function testCreateIteratorWithCustomFilter(): void vfsStream::create($structure); $filter = fn($file) => $file->isFile() && $file->getExtension() === 'php'; - $iterator = $this->fs->createIterator($this->vfsPath, customFilter: $filter); + $iterator = $this->fs->find($this->vfsPath, $filter); $files = []; foreach ($iterator as $file) { diff --git a/tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php b/tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php new file mode 100644 index 00000000000..41fde525c7f --- /dev/null +++ b/tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php @@ -0,0 +1,213 @@ +root = vfsStream::setup('root', null, [ + 'visible.txt' => 'content', + '.hidden' => 'hidden content', + 'file.php' => ' [ + 'Controller.php' => ' [ + 'config' => 'git config', + ], + 'vendor' => [ + 'package.php' => ' [ + 'Test.php' => 'getFilename(); + } + + $this->assertContains('visible.txt', $files); + $this->assertContains('file.php', $files); + $this->assertNotContains('.hidden', $files); + $this->assertNotContains('.git', $files); + } + + public function testFileTypeFilterIteratorFiles(): void + { + $directory = new FilesystemIterator( + vfsStream::url('root/src'), + FilesystemIterator::SKIP_DOTS, + ); + + $filtered = new FileTypeFilterIterator($directory, FinderMode::FILES); + + $items = []; + foreach ($filtered as $file) { + $items[] = $file->getFilename(); + } + + $this->assertContains('Controller.php', $items); + $this->assertNotContains('vendor', $items); + $this->assertNotContains('tests', $items); + } + + public function testFileTypeFilterIteratorDirectories(): void + { + $directory = new FilesystemIterator( + vfsStream::url('root/src'), + FilesystemIterator::SKIP_DOTS, + ); + + $filtered = new FileTypeFilterIterator($directory, FinderMode::DIRECTORIES); + + $items = []; + foreach ($filtered as $file) { + $items[] = $file->getFilename(); + } + + $this->assertContains('vendor', $items); + $this->assertContains('tests', $items); + $this->assertContains('.git', $items); + $this->assertNotContains('Controller.php', $items); + } + + public function testFileTypeFilterIteratorAll(): void + { + $directory = new FilesystemIterator( + vfsStream::url('root/src'), + FilesystemIterator::SKIP_DOTS, + ); + + $filtered = new FileTypeFilterIterator($directory, FinderMode::ALL); + + $items = []; + foreach ($filtered as $file) { + $items[] = $file->getFilename(); + } + + $this->assertContains('Controller.php', $items); + $this->assertContains('vendor', $items); + $this->assertContains('tests', $items); + } + + public function testExcludeDirectoryFilterIterator(): void + { + $directory = new RecursiveDirectoryIterator( + vfsStream::url('root/src'), + FilesystemIterator::SKIP_DOTS, + ); + + $filtered = new ExcludeDirectoryFilterIterator($directory, ['vendor', 'tests']); + $iterator = new RecursiveIteratorIterator($filtered); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('Controller.php', $files); + $this->assertNotContains('package.php', $files); // in vendor + $this->assertNotContains('Test.php', $files); // in tests + } + + public function testExcludeDirectoryFilterIteratorFilesPass(): void + { + $directory = new RecursiveDirectoryIterator( + vfsStream::url('root/src'), + FilesystemIterator::SKIP_DOTS, + ); + + // Even if filename matches excluded name, files should pass + $filtered = new ExcludeDirectoryFilterIterator($directory, ['Controller.php']); + $iterator = new RecursiveIteratorIterator($filtered); + + $files = []; + foreach ($iterator as $file) { + $files[] = $file->getFilename(); + } + + // File with same name should still be included + $this->assertContains('Controller.php', $files); + } + + public function testCombineFilters(): void + { + $directory = new RecursiveDirectoryIterator( + vfsStream::url('root'), + FilesystemIterator::SKIP_DOTS, + ); + + // Combine hidden file filter and exclude directory filter + $filtered = new HiddenFileFilterIterator($directory); + $filtered = new ExcludeDirectoryFilterIterator($filtered, ['vendor']); + $iterator = new RecursiveIteratorIterator($filtered); + $filtered = new FileTypeFilterIterator($iterator, FinderMode::FILES); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('visible.txt', $files); + $this->assertContains('file.php', $files); + $this->assertContains('Controller.php', $files); + $this->assertNotContains('.hidden', $files); // hidden + $this->assertNotContains('config', $files); // in .git (hidden) + $this->assertNotContains('package.php', $files); // in vendor (excluded) + } +} diff --git a/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php new file mode 100644 index 00000000000..6a0ff6e7bf5 --- /dev/null +++ b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php @@ -0,0 +1,416 @@ + [ + 'Controller' => [ + 'AppController.php' => ' ' [ + 'User.php' => ' ' [ + 'index.ctp' => '

Hello

', + 'layout.ctp' => '', + ], + ], + 'tests' => [ + 'TestCase' => [ + 'UserTest.php' => ' ' '# Project', + 'composer.json' => '{}', + ]; + + $this->root = vfsStream::setup('root', null, $structure); + } + + /** + * Test FilenameFilterIterator with simple pattern + */ + public function testFilenameFilterSimplePattern(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new FilenameFilterIterator($recursiveIterator, ['*.php']); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + $this->assertCount(6, $files); + $this->assertContains('AppController.php', $files); + $this->assertContains('UserTest.php', $files); + $this->assertNotContains('index.ctp', $files); + $this->assertNotContains('README.md', $files); + } + + /** + * Test FilenameFilterIterator with multiple patterns + */ + public function testFilenameFilterMultiplePatterns(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new FilenameFilterIterator($recursiveIterator, ['*.md', '*.json']); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + $this->assertCount(2, $files); + $this->assertContains('README.md', $files); + $this->assertContains('composer.json', $files); + } + + /** + * Test PathFilterIterator with include mode + */ + public function testMultiplePcreFilter(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new MultiplePcreFilterIterator($recursiveIterator, [ + '/Controller\.php$/', + '/Test\.php$/', + ]); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + $this->assertCount(4, $files); + $this->assertContains('AppController.php', $files); + $this->assertContains('UsersController.php', $files); + $this->assertContains('UserTest.php', $files); + $this->assertContains('PostTest.php', $files); + $this->assertNotContains('User.php', $files); + $this->assertNotContains('Post.php', $files); + } + + /** + * Test DepthFilterIterator with EQUAL operator + */ + public function testDepthFilterEqual(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new DepthFilterIterator($recursiveIterator, DepthOperator::EQUAL, 0); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + // Only root level items (2 files) + $this->assertCount(2, $files); + $this->assertContains('README.md', $files); + $this->assertContains('composer.json', $files); + } + + /** + * Test DepthFilterIterator with GREATER_THAN operator + */ + public function testDepthFilterGreaterThan(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new DepthFilterIterator($recursiveIterator, DepthOperator::GREATER_THAN, 1); + + $files = []; + foreach ($filtered as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + // Files at depth > 1 (inside Controller, Model, View, TestCase directories) + $this->assertCount(8, $files); + $this->assertContains('AppController.php', $files); + $this->assertContains('UserTest.php', $files); + } + + /** + * Test DepthFilterIterator with LESS_THAN_OR_EQUAL operator + */ + public function testDepthFilterLessThanOrEqual(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new DepthFilterIterator($recursiveIterator, DepthOperator::LESS_THAN_OR_EQUAL, 0); + + $files = []; + foreach ($filtered as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + // Only files at depth 0 + $this->assertCount(2, $files); + $this->assertContains('README.md', $files); + $this->assertContains('composer.json', $files); + } + + /** + * Test NotFilenameFilterIterator with single pattern + */ + public function testNotFilenameFilterSinglePattern(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new NotFilenameFilterIterator($recursiveIterator, ['*Test.php']); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + // Should exclude UserTest.php and PostTest.php + $this->assertNotContains('UserTest.php', $files); + $this->assertNotContains('PostTest.php', $files); + $this->assertContains('AppController.php', $files); + $this->assertContains('User.php', $files); + } + + /** + * Test NotFilenameFilterIterator with multiple patterns + */ + public function testNotFilenameFilterMultiplePatterns(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new NotFilenameFilterIterator($recursiveIterator, ['*.md', '*.json']); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + $this->assertNotContains('README.md', $files); + $this->assertNotContains('composer.json', $files); + $this->assertContains('AppController.php', $files); + } + + /** + * Test NotContainsPathFilterIterator with single pattern + */ + public function testNotContainsPathFilterSinglePattern(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $filtered = new NotContainsPathFilterIterator($iterator, ['Controller']); + + $recursiveIterator = new RecursiveIteratorIterator($filtered); + $files = []; + foreach ($recursiveIterator as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + // Should exclude files with 'Controller' in path + $this->assertNotContains('AppController.php', $files); + $this->assertNotContains('UsersController.php', $files); + $this->assertContains('User.php', $files); + $this->assertContains('UserTest.php', $files); + } + + /** + * Test NotContainsPathFilterIterator allows directory traversal + */ + public function testNotContainsPathFilterAllowsDirectoryTraversal(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $filtered = new NotContainsPathFilterIterator($iterator, ['Controller']); + + $recursiveIterator = new RecursiveIteratorIterator($filtered); + $files = []; + foreach ($recursiveIterator as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + // Should still find files in other directories even though traversing through src + $this->assertGreaterThan(0, $files); + $this->assertContains('User.php', $files); + } + + /** + * Test ContainsPathFilterIterator with single pattern + */ + public function testContainsPathFilterSinglePattern(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new ContainsPathFilterIterator($recursiveIterator, ['Model']); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + // Should only include files with 'Model' in path + $this->assertCount(2, $files); + $this->assertContains('User.php', $files); + $this->assertContains('Post.php', $files); + } + + /** + * Test ContainsPathFilterIterator with multiple patterns (OR logic) + */ + public function testContainsPathFilterMultiplePatterns(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new ContainsPathFilterIterator($recursiveIterator, ['Controller', 'TestCase']); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + // Should include files with either 'Controller' or 'TestCase' in path + $this->assertContains('AppController.php', $files); + $this->assertContains('UsersController.php', $files); + $this->assertContains('UserTest.php', $files); + $this->assertContains('PostTest.php', $files); + $this->assertNotContains('User.php', $files); // In Model, not Controller/TestCase + } + + /** + * Test GlobFilterIterator with simple pattern + */ + public function testGlobFilterSimplePattern(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new GlobFilterIterator($recursiveIterator, ['src/**/*.php'], $this->root->url()); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + // Should match all PHP files under src/ + $this->assertContains('AppController.php', $files); + $this->assertContains('User.php', $files); + $this->assertNotContains('UserTest.php', $files); // In tests/, not src/ + } + + /** + * Test GlobFilterIterator with multiple patterns + */ + public function testGlobFilterMultiplePatterns(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new GlobFilterIterator( + $recursiveIterator, + ['**/*Test.php', '*.md'], + $this->root->url(), + ); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + // Should match test files and markdown files + $this->assertContains('UserTest.php', $files); + $this->assertContains('PostTest.php', $files); + $this->assertContains('README.md', $files); + $this->assertNotContains('User.php', $files); + } +} From 0633f78654f72c4646d3284acdee88bbdf7ae9ba Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 10 Jan 2026 12:20:25 +0100 Subject: [PATCH 020/100] Add more tests --- tests/TestCase/Utility/Fs/FinderTest.php | 292 ++++++++++++++++++ .../Fs/Iterator/PatternFilterIteratorTest.php | 79 +++++ 2 files changed, 371 insertions(+) diff --git a/tests/TestCase/Utility/Fs/FinderTest.php b/tests/TestCase/Utility/Fs/FinderTest.php index 8ebc0bbe860..f51423afcc7 100644 --- a/tests/TestCase/Utility/Fs/FinderTest.php +++ b/tests/TestCase/Utility/Fs/FinderTest.php @@ -985,4 +985,296 @@ public function testDirectoriesWithNamePattern(): void $this->assertCount(1, $paths); $this->assertContains('TestCase', $paths); } + + public function testAllMode(): void + { + $finder = new Finder(); + $items = $finder + ->in(vfsStream::url('root/src')) + ->depth(0) + ->all(); + + $files = []; + $directories = []; + foreach ($items as $item) { + if ($item->isDir()) { + $directories[] = $item->getFilename(); + } else { + $files[] = $item->getFilename(); + } + } + + // Should find both files and directories at depth 0 + $this->assertGreaterThan(0, $files); + $this->assertGreaterThan(0, $directories); + $this->assertContains('Controller', $directories); + $this->assertContains('Model', $directories); + $this->assertContains('View', $directories); + } + + public function testAllModeRecursive(): void + { + $finder = new Finder(); + $items = $finder + ->in(vfsStream::url('root/src/Model')) + ->all(); + + $files = []; + $directories = []; + foreach ($items as $item) { + if ($item->isDir()) { + $directories[] = $item->getFilename(); + } else { + $files[] = $item->getFilename(); + } + } + + // Should find both Entity and Table directories + $this->assertContains('Entity', $directories); + $this->assertContains('Table', $directories); + // And their files + $this->assertContains('User.php', $files); + $this->assertContains('UsersTable.php', $files); + } + + public function testRegexPathPattern(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->path('/Controller/') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + // Should match files with 'Controller' in path using regex + $this->assertGreaterThan(0, $paths); + $this->assertStringContainsString('Controller', implode(',', $paths)); + $this->assertStringContainsString('UsersController.php', implode(',', $paths)); + } + + public function testRegexPathPatternWithDelimiters(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->path('#Model/(Entity|Table)#') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + // Should match files in Model/Entity or Model/Table + $this->assertCount(2, $paths); + $this->assertStringContainsString('User.php', implode(',', $paths)); + $this->assertStringContainsString('UsersTable.php', implode(',', $paths)); + } + + public function testMultipleNamePatterns(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/webroot')) + ->name('*.css') + ->name('*.js') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + // Should match both CSS and JS files + $this->assertCount(2, $paths); + $this->assertContains('style.css', $paths); + $this->assertContains('app.js', $paths); + } + + public function testDepthNotEqual(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->depth(0, DepthOperator::NOT_EQUAL) + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + // Should exclude depth 0 files, only get nested files + $this->assertGreaterThan(0, $paths); + // All paths should have at least one subdirectory + foreach ($paths as $path) { + $relativePath = str_replace(vfsStream::url('root/src') . '/', '', $path); + $this->assertStringContainsString('/', $relativePath); + } + } + + public function testDepthGreaterThan(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->depth(0, DepthOperator::GREATER_THAN) + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + // Should only get files deeper than depth 0 + $this->assertGreaterThan(0, $paths); + foreach ($paths as $path) { + $relativePath = str_replace(vfsStream::url('root/src') . '/', '', $path); + $this->assertStringContainsString('/', $relativePath); + } + } + + public function testDepthLessThanOrEqual(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->depth(1, DepthOperator::LESS_THAN_OR_EQUAL) + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + // Should get files at depth 0 and 1 + $this->assertGreaterThan(0, $paths); + // Should include layout.php (depth 1: src/View/layout.php) + $this->assertContains('layout.php', $paths); + // Should NOT include User.php (depth 2: src/Model/Entity/User.php) + $this->assertNotContains('User.php', $paths); + } + + public function testDepthGreaterThanOrEqual(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->depth(1, DepthOperator::GREATER_THAN_OR_EQUAL) + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + // Should get files at depth 1 and deeper + $this->assertGreaterThan(0, $paths); + // Should include both depth 1 and depth 2 files + $this->assertContains('layout.php', $paths); // depth 1 + $this->assertContains('User.php', $paths); // depth 2 + } + + public function testDepthRangeQuery(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->depth(0, DepthOperator::GREATER_THAN) // Greater than 0 + ->depth(2, DepthOperator::LESS_THAN) // Less than 2 + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + // Should only get files at depth 1 (between 0 and 2) + $this->assertGreaterThan(0, $paths); + $this->assertContains('layout.php', $paths); // depth 1 + $this->assertContains('AppController.php', $paths); // depth 1 + $this->assertNotContains('User.php', $paths); // depth 2 + } + + public function testIgnoreHiddenFilesFalse(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/webroot')) + ->ignoreHiddenFiles(false) + ->recursive(false) + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getFilename(); + } + + // Should include .htaccess file + $this->assertContains('.htaccess', $paths); + $this->assertContains('index.php', $paths); + } + + public function testEmptyDirectory(): void + { + // Create an empty directory + vfsStream::create(['empty' => []], $this->root); + + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/empty')) + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + // Should return no files + $this->assertSame(0, $count); + } + + public function testConflictingNameFilters(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('*Controller.php') + ->notName('*Controller.php') + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + // Should return no files due to conflicting filters + $this->assertSame(0, $count); + } + + public function testMultiplePathPatternsOr(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->path('Controller') + ->path('Entity') + ->files(); + + $paths = []; + foreach ($files as $file) { + $paths[] = $file->getPathname(); + } + + // Should match files with either Controller OR Entity in path + $this->assertGreaterThan(0, $paths); + $this->assertStringContainsString('Controller', implode(',', $paths)); + $this->assertStringContainsString('Entity', implode(',', $paths)); + $this->assertStringContainsString('User.php', implode(',', $paths)); + $this->assertStringContainsString('UsersController.php', implode(',', $paths)); + } } diff --git a/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php index 6a0ff6e7bf5..8e768aef23d 100644 --- a/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php +++ b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php @@ -219,6 +219,85 @@ public function testDepthFilterLessThanOrEqual(): void $this->assertContains('composer.json', $files); } + /** + * Test DepthFilterIterator with NOT_EQUAL operator + */ + public function testDepthFilterNotEqual(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new DepthFilterIterator($recursiveIterator, DepthOperator::NOT_EQUAL, 0); + + $files = []; + foreach ($filtered as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + // All files except depth 0 (excludes README.md and composer.json, only 8 files at depth 2) + $this->assertCount(8, $files); + $this->assertContains('AppController.php', $files); + $this->assertContains('UserTest.php', $files); + $this->assertNotContains('README.md', $files); + $this->assertNotContains('composer.json', $files); + } + + /** + * Test DepthFilterIterator with LESS_THAN operator + */ + public function testDepthFilterLessThan(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new DepthFilterIterator($recursiveIterator, DepthOperator::LESS_THAN, 1); + + $files = []; + foreach ($filtered as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + // Only files at depth < 1 (depth 0) + $this->assertCount(2, $files); + $this->assertContains('README.md', $files); + $this->assertContains('composer.json', $files); + } + + /** + * Test DepthFilterIterator with GREATER_THAN_OR_EQUAL operator + */ + public function testDepthFilterGreaterThanOrEqual(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new DepthFilterIterator($recursiveIterator, DepthOperator::GREATER_THAN_OR_EQUAL, 2); + + $files = []; + foreach ($filtered as $file) { + if ($file->isFile()) { + $files[] = $file->getFilename(); + } + } + + // Files at depth >= 2 (inside Controller, Model, View, TestCase directories) + $this->assertCount(8, $files); + $this->assertContains('AppController.php', $files); + $this->assertContains('UsersController.php', $files); + $this->assertContains('User.php', $files); + $this->assertContains('Post.php', $files); + } + /** * Test NotFilenameFilterIterator with single pattern */ From 49ab6f74f63063fd2d8a36d66781ba4d522d8cdf Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 10 Jan 2026 14:53:42 +0100 Subject: [PATCH 021/100] Normalize some more cross platform path cases --- src/Utility/Fs/Iterator/ContainsPathFilterIterator.php | 7 +++++-- src/Utility/Fs/Iterator/DepthFilterIterator.php | 2 +- src/Utility/Fs/Iterator/FilenameFilterIterator.php | 2 +- src/Utility/Fs/Iterator/GlobFilterIterator.php | 2 +- src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php | 5 +++-- src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php | 7 +++++-- src/Utility/Fs/Iterator/NotFilenameFilterIterator.php | 2 +- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php index 4cc24767e31..b231e56f213 100644 --- a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php +++ b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php @@ -11,11 +11,12 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Iterator; +use Cake\Utility\Fs\Path; use FilterIterator; use Iterator; @@ -36,6 +37,8 @@ public function __construct( protected array $patterns, ) { parent::__construct($iterator); + // Normalize patterns once for cross-platform compatibility + $this->patterns = array_map(fn(string $p) => Path::normalize($p), $this->patterns); } /** @@ -43,7 +46,7 @@ public function __construct( */ public function accept(): bool { - $path = $this->current()->getPathname(); + $path = Path::normalize($this->current()->getPathname()); foreach ($this->patterns as $pattern) { if (str_contains($path, $pattern)) { diff --git a/src/Utility/Fs/Iterator/DepthFilterIterator.php b/src/Utility/Fs/Iterator/DepthFilterIterator.php index 7834ad2309d..810317adc78 100644 --- a/src/Utility/Fs/Iterator/DepthFilterIterator.php +++ b/src/Utility/Fs/Iterator/DepthFilterIterator.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Iterator; diff --git a/src/Utility/Fs/Iterator/FilenameFilterIterator.php b/src/Utility/Fs/Iterator/FilenameFilterIterator.php index cdd379b8bcd..54b9ba1697e 100644 --- a/src/Utility/Fs/Iterator/FilenameFilterIterator.php +++ b/src/Utility/Fs/Iterator/FilenameFilterIterator.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Iterator; diff --git a/src/Utility/Fs/Iterator/GlobFilterIterator.php b/src/Utility/Fs/Iterator/GlobFilterIterator.php index b548345f3a4..8dc1423e339 100644 --- a/src/Utility/Fs/Iterator/GlobFilterIterator.php +++ b/src/Utility/Fs/Iterator/GlobFilterIterator.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Iterator; diff --git a/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php b/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php index a4cd6970302..aa26e3e1363 100644 --- a/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php +++ b/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php @@ -11,11 +11,12 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Iterator; +use Cake\Utility\Fs\Path; use FilterIterator; use Iterator; @@ -42,7 +43,7 @@ public function __construct( */ public function accept(): bool { - $path = $this->current()->getPathname(); + $path = Path::normalize($this->current()->getPathname()); foreach ($this->patterns as $pattern) { if (preg_match($pattern, $path)) { diff --git a/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php index 158bd88c80c..fc54fa16867 100644 --- a/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php +++ b/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php @@ -11,11 +11,12 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Iterator; +use Cake\Utility\Fs\Path; use RecursiveFilterIterator; use RecursiveIterator; @@ -36,6 +37,8 @@ public function __construct( protected array $patterns, ) { parent::__construct($iterator); + // Normalize patterns once for cross-platform compatibility + $this->patterns = array_map(fn(string $p) => Path::normalize($p), $this->patterns); } /** @@ -51,7 +54,7 @@ public function accept(): bool } // For files, check if path contains excluded patterns - $path = $current->getPathname(); + $path = Path::normalize($current->getPathname()); foreach ($this->patterns as $pattern) { if (str_contains($path, $pattern)) { return false; diff --git a/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php b/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php index c330af4e12e..77e7ba82c03 100644 --- a/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php +++ b/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Utility\Fs\Iterator; From ac2217b027e8cc04965caee3026ed78db49fbed4 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 10 Jan 2026 16:12:19 +0100 Subject: [PATCH 022/100] Add custom filter iterator --- src/Utility/Fs/Finder.php | 37 +++++- .../Fs/Iterator/CallbackFilterIterator.php | 61 +++++++++ .../Fs/Iterator/DepthFilterIterator.php | 4 +- .../ExcludeDirectoryFilterIterator.php | 2 +- .../Fs/Iterator/FileTypeFilterIterator.php | 10 +- .../Fs/Iterator/FilenameFilterIterator.php | 2 +- .../Fs/Iterator/GlobFilterIterator.php | 4 +- .../Iterator/MultiplePcreFilterIterator.php | 2 +- .../Fs/Iterator/NotFilenameFilterIterator.php | 2 +- tests/TestCase/Utility/Fs/FinderTest.php | 116 +++++++++++++++++- .../Fs/Iterator/FilterIteratorTest.php | 30 ++++- .../Fs/Iterator/PatternFilterIteratorTest.php | 2 +- tests/TestCase/Utility/Fs/PathTest.php | 2 +- 13 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 src/Utility/Fs/Iterator/CallbackFilterIterator.php diff --git a/src/Utility/Fs/Finder.php b/src/Utility/Fs/Finder.php index 1feaffb86bb..3a6a700f540 100644 --- a/src/Utility/Fs/Finder.php +++ b/src/Utility/Fs/Finder.php @@ -19,6 +19,7 @@ use AppendIterator; use Cake\Utility\Fs\Enum\DepthOperator; use Cake\Utility\Fs\Enum\FinderMode; +use Cake\Utility\Fs\Iterator\CallbackFilterIterator; use Cake\Utility\Fs\Iterator\ContainsPathFilterIterator; use Cake\Utility\Fs\Iterator\DepthFilterIterator; use Cake\Utility\Fs\Iterator\ExcludeDirectoryFilterIterator; @@ -29,6 +30,7 @@ use Cake\Utility\Fs\Iterator\MultiplePcreFilterIterator; use Cake\Utility\Fs\Iterator\NotContainsPathFilterIterator; use Cake\Utility\Fs\Iterator\NotFilenameFilterIterator; +use Closure; use FilesystemIterator; use Iterator; use RecursiveDirectoryIterator; @@ -142,6 +144,13 @@ class Finder */ protected ?FinderMode $mode = null; + /** + * Custom filter callbacks + * + * @var array<\Closure(\SplFileInfo, string): bool> + */ + protected array $filters = []; + /** * Add a path to search in. * @@ -235,6 +244,27 @@ public function pattern(string $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. * @@ -407,7 +437,12 @@ protected function buildIterator(string $path): Iterator // Apply glob pattern filtering if ($this->globPatterns !== []) { - return new GlobFilterIterator($iterator, $this->globPatterns, $path); + $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..0ffe742adb3 --- /dev/null +++ b/src/Utility/Fs/Iterator/CallbackFilterIterator.php @@ -0,0 +1,61 @@ +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/DepthFilterIterator.php b/src/Utility/Fs/Iterator/DepthFilterIterator.php index 810317adc78..074d96c0660 100644 --- a/src/Utility/Fs/Iterator/DepthFilterIterator.php +++ b/src/Utility/Fs/Iterator/DepthFilterIterator.php @@ -41,8 +41,8 @@ final class DepthFilterIterator extends FilterIterator */ public function __construct( Iterator $iterator, - protected DepthOperator $operator, - protected int $value, + protected readonly DepthOperator $operator, + protected readonly int $value, ) { parent::__construct($iterator); } diff --git a/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php index 2733b0b5de6..5577d364b1a 100644 --- a/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php +++ b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php @@ -37,7 +37,7 @@ final class ExcludeDirectoryFilterIterator extends RecursiveFilterIterator */ public function __construct( RecursiveIterator $iterator, - protected array $excludeDirs, + protected readonly array $excludeDirs, ) { parent::__construct($iterator); } diff --git a/src/Utility/Fs/Iterator/FileTypeFilterIterator.php b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php index 89b3e171d16..ec174ae0d3a 100644 --- a/src/Utility/Fs/Iterator/FileTypeFilterIterator.php +++ b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php @@ -28,21 +28,13 @@ class FileTypeFilterIterator extends FilterIterator { /** - * @var \Cake\Utility\Fs\Enum\FinderMode - */ - protected FinderMode $mode; - - /** - * Constructor. - * * @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, - FinderMode $mode, + protected readonly FinderMode $mode, ) { - $this->mode = $mode; parent::__construct($iterator); } diff --git a/src/Utility/Fs/Iterator/FilenameFilterIterator.php b/src/Utility/Fs/Iterator/FilenameFilterIterator.php index 54b9ba1697e..104507fb506 100644 --- a/src/Utility/Fs/Iterator/FilenameFilterIterator.php +++ b/src/Utility/Fs/Iterator/FilenameFilterIterator.php @@ -36,7 +36,7 @@ final class FilenameFilterIterator extends FilterIterator */ public function __construct( Iterator $iterator, - protected array $patterns, + protected readonly array $patterns, ) { parent::__construct($iterator); } diff --git a/src/Utility/Fs/Iterator/GlobFilterIterator.php b/src/Utility/Fs/Iterator/GlobFilterIterator.php index 8dc1423e339..8a186d6566b 100644 --- a/src/Utility/Fs/Iterator/GlobFilterIterator.php +++ b/src/Utility/Fs/Iterator/GlobFilterIterator.php @@ -37,8 +37,8 @@ final class GlobFilterIterator extends FilterIterator */ public function __construct( Iterator $iterator, - protected array $patterns, - protected string $basePath, + protected readonly array $patterns, + protected readonly string $basePath, ) { parent::__construct($iterator); } diff --git a/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php b/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php index aa26e3e1363..ae163a8bf37 100644 --- a/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php +++ b/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php @@ -33,7 +33,7 @@ final class MultiplePcreFilterIterator extends FilterIterator */ public function __construct( Iterator $iterator, - protected array $patterns, + protected readonly array $patterns, ) { parent::__construct($iterator); } diff --git a/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php b/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php index 77e7ba82c03..e4c64e863fc 100644 --- a/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php +++ b/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php @@ -33,7 +33,7 @@ final class NotFilenameFilterIterator extends FilterIterator */ public function __construct( Iterator $iterator, - protected array $patterns, + protected readonly array $patterns, ) { parent::__construct($iterator); } diff --git a/tests/TestCase/Utility/Fs/FinderTest.php b/tests/TestCase/Utility/Fs/FinderTest.php index f51423afcc7..7ef36cc8105 100644 --- a/tests/TestCase/Utility/Fs/FinderTest.php +++ b/tests/TestCase/Utility/Fs/FinderTest.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.3.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Test\TestCase\Utility\Fs; @@ -21,6 +21,7 @@ use Cake\Utility\Fs\Finder; use Iterator; use org\bovigo\vfs\vfsStream; +use SplFileInfo; /** * FinderTest class @@ -1277,4 +1278,117 @@ public function testMultiplePathPatternsOr(): void $this->assertStringContainsString('User.php', implode(',', $paths)); $this->assertStringContainsString('UsersController.php', implode(',', $paths)); } + + /** + * Test filter() with single callback + */ + public function testFilterSingleCallback(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->filter(function (SplFileInfo $file) { + // Only files with "Controller" in name + return str_contains($file->getFilename(), 'Controller'); + }) + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + $this->assertCount(2, $filenames); + $this->assertContains('AppController.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + } + + /** + * Test filter() with multiple chained callbacks (AND logic) + */ + public function testFilterMultipleCallbacks(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->filter(fn(SplFileInfo $file) => str_contains($file->getFilename(), 'User')) + ->filter(fn(SplFileInfo $file) => !str_contains($file->getFilename(), 'Table')) + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + // Should find User.php and UsersController.php, but not UsersTable.php + $this->assertCount(2, $filenames); + $this->assertContains('User.php', $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertNotContains('UsersTable.php', $filenames); + } + + /** + * Test filter() with relative path parameter + */ + public function testFilterWithRelativePath(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root')) + ->filter(function (SplFileInfo $file, string $relativePath) { + // Only files in src directory + return str_starts_with($relativePath, 'src'); + }) + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + $this->assertStringContainsString('src', $file->getPathname()); + } + + $this->assertGreaterThan(0, $count); + } + + /** + * Test filter() combined with other filters + */ + public function testFilterWithOtherFilters(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->name('*Controller.php') + ->filter(fn(SplFileInfo $file) => strlen($file->getFilename()) > 18) + ->files(); + + $filenames = []; + foreach ($files as $file) { + $filenames[] = $file->getFilename(); + } + + // UsersController.php (20 chars) is >18, AppController.php (17 chars) is not + $this->assertCount(1, $filenames); + $this->assertContains('UsersController.php', $filenames); + $this->assertNotContains('AppController.php', $filenames); + } + + /** + * Test filter() returning empty results + */ + public function testFilterNoMatches(): void + { + $finder = new Finder(); + $files = $finder + ->in(vfsStream::url('root/src')) + ->filter(fn(SplFileInfo $file) => str_contains($file->getFilename(), 'NonExistent')) + ->files(); + + $count = 0; + foreach ($files as $file) { + $count++; + } + + $this->assertSame(0, $count); + } } diff --git a/tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php b/tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php index 41fde525c7f..d680bcf9ee7 100644 --- a/tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php +++ b/tests/TestCase/Utility/Fs/Iterator/FilterIteratorTest.php @@ -11,13 +11,14 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.3.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Test\TestCase\Utility\Fs\Iterator; use Cake\TestSuite\TestCase; use Cake\Utility\Fs\Enum\FinderMode; +use Cake\Utility\Fs\Iterator\CallbackFilterIterator; use Cake\Utility\Fs\Iterator\ExcludeDirectoryFilterIterator; use Cake\Utility\Fs\Iterator\FileTypeFilterIterator; use Cake\Utility\Fs\Iterator\HiddenFileFilterIterator; @@ -25,6 +26,7 @@ use org\bovigo\vfs\vfsStream; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use SplFileInfo; /** * FilterIteratorTest class @@ -210,4 +212,30 @@ public function testCombineFilters(): void $this->assertNotContains('config', $files); // in .git (hidden) $this->assertNotContains('package.php', $files); // in vendor (excluded) } + + /** + * Test CallbackFilterIterator basic filtering + */ + public function testCallbackFilterIterator(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + $recursiveIterator = new RecursiveIteratorIterator($iterator); + $filtered = new CallbackFilterIterator( + $recursiveIterator, + fn(SplFileInfo $file) => str_ends_with($file->getFilename(), '.php'), + $this->root->url(), + ); + + $files = []; + foreach ($filtered as $file) { + $files[] = $file->getFilename(); + } + + $this->assertContains('file.php', $files); + $this->assertContains('Controller.php', $files); + $this->assertNotContains('visible.txt', $files); + } } diff --git a/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php index 8e768aef23d..6a6bf3c0251 100644 --- a/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php +++ b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.2.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Test\TestCase\Utility\Fs\Iterator; diff --git a/tests/TestCase/Utility/Fs/PathTest.php b/tests/TestCase/Utility/Fs/PathTest.php index 9bc44262214..e44eb4a1735 100644 --- a/tests/TestCase/Utility/Fs/PathTest.php +++ b/tests/TestCase/Utility/Fs/PathTest.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * @link https://cakephp.org CakePHP(tm) Project - * @since 5.3.0 + * @since 5.4.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Test\TestCase\Utility\Fs; From 1f3c4ffb1f1a0048fbde7289f8838ec52a2acecb Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Mon, 12 Jan 2026 11:59:49 +0100 Subject: [PATCH 023/100] Simplify some iterators by adding negate and consolidate pcre + path filters --- src/Utility/Fs/Finder.php | 28 ++--- .../Fs/Iterator/CallbackFilterIterator.php | 3 - .../Iterator/ContainsPathFilterIterator.php | 112 +++++++++++++++--- .../Fs/Iterator/FilenameFilterIterator.php | 12 +- .../Iterator/MultiplePcreFilterIterator.php | 56 --------- .../NotContainsPathFilterIterator.php | 77 ------------ .../Fs/Iterator/NotFilenameFilterIterator.php | 56 --------- .../Fs/Iterator/PatternFilterIteratorTest.php | 76 ++++++++---- 8 files changed, 163 insertions(+), 257 deletions(-) delete mode 100644 src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php delete mode 100644 src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php delete mode 100644 src/Utility/Fs/Iterator/NotFilenameFilterIterator.php diff --git a/src/Utility/Fs/Finder.php b/src/Utility/Fs/Finder.php index 3a6a700f540..7bab0abc501 100644 --- a/src/Utility/Fs/Finder.php +++ b/src/Utility/Fs/Finder.php @@ -27,9 +27,6 @@ use Cake\Utility\Fs\Iterator\FileTypeFilterIterator; use Cake\Utility\Fs\Iterator\GlobFilterIterator; use Cake\Utility\Fs\Iterator\HiddenFileFilterIterator; -use Cake\Utility\Fs\Iterator\MultiplePcreFilterIterator; -use Cake\Utility\Fs\Iterator\NotContainsPathFilterIterator; -use Cake\Utility\Fs\Iterator\NotFilenameFilterIterator; use Closure; use FilesystemIterator; use Iterator; @@ -387,7 +384,12 @@ protected function buildIterator(string $path): Iterator // Apply path pattern exclusions during recursion if ($this->notPathPatterns !== []) { - $directory = new NotContainsPathFilterIterator($directory, $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 @@ -408,23 +410,7 @@ protected function buildIterator(string $path): Iterator $iterator = new FilenameFilterIterator($iterator, $this->names); } if ($this->notNames !== []) { - $iterator = new NotFilenameFilterIterator($iterator, $this->notNames); - } - - // Apply path pattern inclusions - if ($this->pathPatterns !== []) { - // Check if patterns are regex (start with delimiter like /, #, ~) - $hasRegex = false; - foreach ($this->pathPatterns as $pattern) { - if (preg_match('/^[\/#~]/', $pattern)) { - $hasRegex = true; - break; - } - } - - $iterator = $hasRegex - ? new MultiplePcreFilterIterator($iterator, $this->pathPatterns) - : new ContainsPathFilterIterator($iterator, $this->pathPatterns); + $iterator = new FilenameFilterIterator($iterator, $this->notNames, negate: true); } // Apply depth filtering (handles non-recursive mode when recursive=false) diff --git a/src/Utility/Fs/Iterator/CallbackFilterIterator.php b/src/Utility/Fs/Iterator/CallbackFilterIterator.php index 0ffe742adb3..152477c5ecd 100644 --- a/src/Utility/Fs/Iterator/CallbackFilterIterator.php +++ b/src/Utility/Fs/Iterator/CallbackFilterIterator.php @@ -23,9 +23,6 @@ /** * Filters files using a custom callback function. - * - * This is applied as a final stage filter after all built-in filters, - * providing flexibility for edge cases not covered by dedicated iterators. */ final class CallbackFilterIterator extends FilterIterator { diff --git a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php index b231e56f213..3f0a914bc04 100644 --- a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php +++ b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php @@ -17,28 +17,68 @@ namespace Cake\Utility\Fs\Iterator; use Cake\Utility\Fs\Path; -use FilterIterator; -use Iterator; +use RecursiveFilterIterator; +use RecursiveIterator; /** - * Filters files to only include those whose path contains at least one - * of the given patterns. + * Filters files based on whether their path contains or matches specific patterns. * - * Uses simple string matching (str_contains) with OR logic. + * Supports two types of patterns: + * - String patterns: Uses substring matching (str_contains) for simple strings + * - Regex patterns: Uses preg_match for patterns starting with /, #, or ~ + * + * Uses OR logic: accepts if any pattern matches. + * Always allows directories to enable recursive traversal. + * + * Can be used to include or exclude files based on the $negate parameter: + * - When $negate is false (default): includes files matching patterns + * - When $negate is true: excludes files matching patterns */ -final class ContainsPathFilterIterator extends FilterIterator +final class ContainsPathFilterIterator extends RecursiveFilterIterator { /** - * @param \Iterator $iterator The iterator to filter - * @param array $patterns Path patterns to include + * String patterns (substring matching, normalized) + * + * @var array + */ + 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( - Iterator $iterator, - protected array $patterns, + RecursiveIterator $iterator, + array $patterns, + protected readonly bool $negate = false, ) { parent::__construct($iterator); - // Normalize patterns once for cross-platform compatibility - $this->patterns = array_map(fn(string $p) => Path::normalize($p), $this->patterns); + + // 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; } /** @@ -46,14 +86,54 @@ public function __construct( */ public function accept(): bool { - $path = Path::normalize($this->current()->getPathname()); + $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; - foreach ($this->patterns as $pattern) { + // Check string patterns (substring matching) + foreach ($this->stringPatterns as $pattern) { if (str_contains($path, $pattern)) { - return true; + $matches = true; + break; } } - return false; + // 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/FilenameFilterIterator.php b/src/Utility/Fs/Iterator/FilenameFilterIterator.php index 104507fb506..2f50bd2cf07 100644 --- a/src/Utility/Fs/Iterator/FilenameFilterIterator.php +++ b/src/Utility/Fs/Iterator/FilenameFilterIterator.php @@ -27,16 +27,22 @@ * - `*.php` - All PHP files * - `Test*.php` - Files starting with Test * - `{foo,bar}.php` - foo.php or bar.php + * + * Can be used to include or exclude files based on the $negate parameter: + * - When $negate is false (default): includes files matching patterns + * - When $negate is true: excludes files matching patterns */ 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); } @@ -48,12 +54,14 @@ public function accept(): bool { $filename = $this->current()->getFilename(); + $matches = false; foreach ($this->patterns as $pattern) { if (Path::matches($pattern, $filename)) { - return true; + $matches = true; + break; } } - return false; + return $this->negate ? !$matches : $matches; } } diff --git a/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php b/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php deleted file mode 100644 index ae163a8bf37..00000000000 --- a/src/Utility/Fs/Iterator/MultiplePcreFilterIterator.php +++ /dev/null @@ -1,56 +0,0 @@ - $iterator The iterator to filter - * @param array $patterns Regular expressions to match against - */ - public function __construct( - Iterator $iterator, - protected readonly array $patterns, - ) { - parent::__construct($iterator); - } - - /** - * @inheritDoc - */ - public function accept(): bool - { - $path = Path::normalize($this->current()->getPathname()); - - foreach ($this->patterns as $pattern) { - if (preg_match($pattern, $path)) { - return true; - } - } - - return false; - } -} diff --git a/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php deleted file mode 100644 index fc54fa16867..00000000000 --- a/src/Utility/Fs/Iterator/NotContainsPathFilterIterator.php +++ /dev/null @@ -1,77 +0,0 @@ - $patterns Path patterns to exclude - */ - public function __construct( - RecursiveIterator $iterator, - protected array $patterns, - ) { - parent::__construct($iterator); - // Normalize patterns once for cross-platform compatibility - $this->patterns = array_map(fn(string $p) => Path::normalize($p), $this->patterns); - } - - /** - * @inheritDoc - */ - public function accept(): bool - { - $current = $this->current(); - - // Always accept directories to allow traversal - if ($current->isDir()) { - return true; - } - - // For files, check if path contains excluded patterns - $path = Path::normalize($current->getPathname()); - foreach ($this->patterns as $pattern) { - if (str_contains($path, $pattern)) { - return false; - } - } - - return true; - } - - /** - * @inheritDoc - */ - public function getChildren(): self - { - /** @var \RecursiveIterator $inner */ - $inner = $this->getInnerIterator(); - - return new self($inner->getChildren(), $this->patterns); - } -} diff --git a/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php b/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php deleted file mode 100644 index e4c64e863fc..00000000000 --- a/src/Utility/Fs/Iterator/NotFilenameFilterIterator.php +++ /dev/null @@ -1,56 +0,0 @@ - $patterns Glob patterns to exclude - */ - public function __construct( - Iterator $iterator, - protected readonly array $patterns, - ) { - parent::__construct($iterator); - } - - /** - * @inheritDoc - */ - public function accept(): bool - { - $filename = $this->current()->getFilename(); - - foreach ($this->patterns as $pattern) { - if (Path::matches($pattern, $filename)) { - return false; - } - } - - return true; - } -} diff --git a/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php index 6a6bf3c0251..224a0beb91b 100644 --- a/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php +++ b/tests/TestCase/Utility/Fs/Iterator/PatternFilterIteratorTest.php @@ -22,9 +22,6 @@ use Cake\Utility\Fs\Iterator\DepthFilterIterator; use Cake\Utility\Fs\Iterator\FilenameFilterIterator; use Cake\Utility\Fs\Iterator\GlobFilterIterator; -use Cake\Utility\Fs\Iterator\MultiplePcreFilterIterator; -use Cake\Utility\Fs\Iterator\NotContainsPathFilterIterator; -use Cake\Utility\Fs\Iterator\NotFilenameFilterIterator; use org\bovigo\vfs\vfsStream; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -118,22 +115,22 @@ public function testFilenameFilterMultiplePatterns(): void } /** - * Test PathFilterIterator with include mode + * Test ContainsPathFilterIterator with regex patterns */ - public function testMultiplePcreFilter(): void + public function testContainsPathFilterRegex(): void { $iterator = new RecursiveDirectoryIterator( $this->root->url(), RecursiveDirectoryIterator::SKIP_DOTS, ); - $recursiveIterator = new RecursiveIteratorIterator($iterator); - $filtered = new MultiplePcreFilterIterator($recursiveIterator, [ + $filtered = new ContainsPathFilterIterator($iterator, [ '/Controller\.php$/', '/Test\.php$/', ]); + $recursiveIterator = new RecursiveIteratorIterator($filtered); $files = []; - foreach ($filtered as $file) { + foreach ($recursiveIterator as $file) { $files[] = $file->getFilename(); } @@ -299,16 +296,16 @@ public function testDepthFilterGreaterThanOrEqual(): void } /** - * Test NotFilenameFilterIterator with single pattern + * Test FilenameFilterIterator with negate parameter (single pattern) */ - public function testNotFilenameFilterSinglePattern(): void + public function testFilenameFilterNegatedSinglePattern(): void { $iterator = new RecursiveDirectoryIterator( $this->root->url(), RecursiveDirectoryIterator::SKIP_DOTS, ); $recursiveIterator = new RecursiveIteratorIterator($iterator); - $filtered = new NotFilenameFilterIterator($recursiveIterator, ['*Test.php']); + $filtered = new FilenameFilterIterator($recursiveIterator, ['*Test.php'], negate: true); $files = []; foreach ($filtered as $file) { @@ -323,16 +320,16 @@ public function testNotFilenameFilterSinglePattern(): void } /** - * Test NotFilenameFilterIterator with multiple patterns + * Test FilenameFilterIterator with negate parameter (multiple patterns) */ - public function testNotFilenameFilterMultiplePatterns(): void + public function testFilenameFilterNegatedMultiplePatterns(): void { $iterator = new RecursiveDirectoryIterator( $this->root->url(), RecursiveDirectoryIterator::SKIP_DOTS, ); $recursiveIterator = new RecursiveIteratorIterator($iterator); - $filtered = new NotFilenameFilterIterator($recursiveIterator, ['*.md', '*.json']); + $filtered = new FilenameFilterIterator($recursiveIterator, ['*.md', '*.json'], negate: true); $files = []; foreach ($filtered as $file) { @@ -345,15 +342,15 @@ public function testNotFilenameFilterMultiplePatterns(): void } /** - * Test NotContainsPathFilterIterator with single pattern + * Test ContainsPathFilterIterator with negate parameter (single pattern) */ - public function testNotContainsPathFilterSinglePattern(): void + public function testContainsPathFilterNegatedSinglePattern(): void { $iterator = new RecursiveDirectoryIterator( $this->root->url(), RecursiveDirectoryIterator::SKIP_DOTS, ); - $filtered = new NotContainsPathFilterIterator($iterator, ['Controller']); + $filtered = new ContainsPathFilterIterator($iterator, ['Controller'], negate: true); $recursiveIterator = new RecursiveIteratorIterator($filtered); $files = []; @@ -371,15 +368,15 @@ public function testNotContainsPathFilterSinglePattern(): void } /** - * Test NotContainsPathFilterIterator allows directory traversal + * Test ContainsPathFilterIterator with negate allows directory traversal */ - public function testNotContainsPathFilterAllowsDirectoryTraversal(): void + public function testContainsPathFilterNegatedAllowsDirectoryTraversal(): void { $iterator = new RecursiveDirectoryIterator( $this->root->url(), RecursiveDirectoryIterator::SKIP_DOTS, ); - $filtered = new NotContainsPathFilterIterator($iterator, ['Controller']); + $filtered = new ContainsPathFilterIterator($iterator, ['Controller'], negate: true); $recursiveIterator = new RecursiveIteratorIterator($filtered); $files = []; @@ -403,11 +400,11 @@ public function testContainsPathFilterSinglePattern(): void $this->root->url(), RecursiveDirectoryIterator::SKIP_DOTS, ); - $recursiveIterator = new RecursiveIteratorIterator($iterator); - $filtered = new ContainsPathFilterIterator($recursiveIterator, ['Model']); + $filtered = new ContainsPathFilterIterator($iterator, ['Model']); + $recursiveIterator = new RecursiveIteratorIterator($filtered); $files = []; - foreach ($filtered as $file) { + foreach ($recursiveIterator as $file) { $files[] = $file->getFilename(); } @@ -426,11 +423,11 @@ public function testContainsPathFilterMultiplePatterns(): void $this->root->url(), RecursiveDirectoryIterator::SKIP_DOTS, ); - $recursiveIterator = new RecursiveIteratorIterator($iterator); - $filtered = new ContainsPathFilterIterator($recursiveIterator, ['Controller', 'TestCase']); + $filtered = new ContainsPathFilterIterator($iterator, ['Controller', 'TestCase']); + $recursiveIterator = new RecursiveIteratorIterator($filtered); $files = []; - foreach ($filtered as $file) { + foreach ($recursiveIterator as $file) { $files[] = $file->getFilename(); } @@ -442,6 +439,33 @@ public function testContainsPathFilterMultiplePatterns(): void $this->assertNotContains('User.php', $files); // In Model, not Controller/TestCase } + /** + * Test ContainsPathFilterIterator with mixed string and regex patterns + */ + public function testContainsPathFilterMixedPatterns(): void + { + $iterator = new RecursiveDirectoryIterator( + $this->root->url(), + RecursiveDirectoryIterator::SKIP_DOTS, + ); + // Mix string pattern 'TestCase' with regex pattern matching Controller files + $filtered = new ContainsPathFilterIterator($iterator, ['TestCase', '/Controller\.php$/']); + $recursiveIterator = new RecursiveIteratorIterator($filtered); + + $files = []; + foreach ($recursiveIterator as $file) { + $files[] = $file->getFilename(); + } + + // Should include files matching either pattern + $this->assertContains('AppController.php', $files); + $this->assertContains('UsersController.php', $files); + $this->assertContains('UserTest.php', $files); + $this->assertContains('PostTest.php', $files); + $this->assertNotContains('User.php', $files); + $this->assertNotContains('Post.php', $files); + } + /** * Test GlobFilterIterator with simple pattern */ From f18f7d63d75ce4589757fbe1afe6a54e9b997147 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Tue, 13 Jan 2026 21:31:04 -0500 Subject: [PATCH 024/100] Bump dependencines and branch aliases for 5.4 --- src/Cache/composer.json | 6 +++--- src/Collection/composer.json | 2 +- src/Console/composer.json | 10 +++++----- src/Core/composer.json | 4 ++-- src/Database/composer.json | 10 +++++----- src/Datasource/composer.json | 10 +++++----- src/Event/composer.json | 4 ++-- src/Form/composer.json | 6 +++--- src/Http/composer.json | 16 ++++++++-------- src/I18n/composer.json | 4 ++-- src/Log/composer.json | 4 ++-- src/ORM/composer.json | 20 ++++++++++---------- src/Utility/composer.json | 4 ++-- src/Validation/composer.json | 8 ++++---- 14 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/Cache/composer.json b/src/Cache/composer.json index 7f367d89c43..d5c50625d15 100644 --- a/src/Cache/composer.json +++ b/src/Cache/composer.json @@ -23,8 +23,8 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev", - "cakephp/event": "5.3.*@dev", + "cakephp/core": "5.4.*@dev", + "cakephp/event": "5.4.*@dev", "psr/simple-cache": "^2.0 || ^3.0" }, "provide": { @@ -39,7 +39,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Collection/composer.json b/src/Collection/composer.json index 04b0d831399..7dd09ba7deb 100644 --- a/src/Collection/composer.json +++ b/src/Collection/composer.json @@ -35,7 +35,7 @@ }, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Console/composer.json b/src/Console/composer.json index 67c8ae55761..c00fbd72c9f 100644 --- a/src/Console/composer.json +++ b/src/Console/composer.json @@ -24,10 +24,10 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev", - "cakephp/event": "5.3.*@dev", - "cakephp/log": "5.3.*@dev", - "cakephp/utility": "5.3.*@dev" + "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", @@ -42,7 +42,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Core/composer.json b/src/Core/composer.json index 244ad215bf4..518b5618769 100644 --- a/src/Core/composer.json +++ b/src/Core/composer.json @@ -23,7 +23,7 @@ }, "require": { "php": ">=8.2", - "cakephp/utility": "5.3.*@dev", + "cakephp/utility": "5.4.*@dev", "league/container": "^5.1", "psr/container": "^1.1 || ^2.0" }, @@ -47,7 +47,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Database/composer.json b/src/Database/composer.json index 041265a217d..4216e736517 100644 --- a/src/Database/composer.json +++ b/src/Database/composer.json @@ -25,14 +25,14 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev", + "cakephp/core": "5.4.*@dev", "cakephp/chronos": "^3.3", - "cakephp/datasource": "5.3.*@dev", + "cakephp/datasource": "5.4.*@dev", "psr/log": "^3.0" }, "require-dev": { - "cakephp/i18n": "5.3.*@dev", - "cakephp/log": "5.3.*@dev" + "cakephp/i18n": "5.4.*@dev", + "cakephp/log": "5.4.*@dev" }, "autoload": { "psr-4": { @@ -47,7 +47,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Datasource/composer.json b/src/Datasource/composer.json index cd2a0569c2a..aa4f557b92b 100644 --- a/src/Datasource/composer.json +++ b/src/Datasource/composer.json @@ -25,13 +25,13 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev", + "cakephp/core": "5.4.*@dev", "psr/simple-cache": "^2.0 || ^3.0" }, "require-dev": { - "cakephp/cache": "5.3.*@dev", - "cakephp/collection": "5.3.*@dev", - "cakephp/utility": "5.3.*@dev" + "cakephp/cache": "5.4.*@dev", + "cakephp/collection": "5.4.*@dev", + "cakephp/utility": "5.4.*@dev" }, "autoload": { "psr-4": { @@ -47,7 +47,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Event/composer.json b/src/Event/composer.json index 139d0db07cf..d4ef7730345 100644 --- a/src/Event/composer.json +++ b/src/Event/composer.json @@ -24,7 +24,7 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev" + "cakephp/core": "5.4.*@dev" }, "autoload": { "psr-4": { @@ -35,7 +35,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Form/composer.json b/src/Form/composer.json index 2e1a104bb0f..86d018454db 100644 --- a/src/Form/composer.json +++ b/src/Form/composer.json @@ -22,8 +22,8 @@ }, "require": { "php": ">=8.2", - "cakephp/event": "5.3.*@dev", - "cakephp/validation":"5.3.*@dev" + "cakephp/event": "5.4.*@dev", + "cakephp/validation":"5.4.*@dev" }, "autoload": { "psr-4": { @@ -34,7 +34,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Http/composer.json b/src/Http/composer.json index b3f1730d384..014d882d02a 100644 --- a/src/Http/composer.json +++ b/src/Http/composer.json @@ -26,9 +26,9 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev", - "cakephp/event": "5.3.*@dev", - "cakephp/utility": "5.3.*@dev", + "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", @@ -39,10 +39,10 @@ "laminas/laminas-httphandlerrunner": "^2.6" }, "require-dev": { - "cakephp/cache": "5.3.*@dev", - "cakephp/console": "5.3.*@dev", - "cakephp/orm": "5.3.*@dev", - "cakephp/i18n": "5.3.*@dev", + "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": { @@ -65,7 +65,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/I18n/composer.json b/src/I18n/composer.json index 7d0cc4369d5..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.*@dev", + "cakephp/core": "5.4.*@dev", "cakephp/chronos": "^3.3" }, "autoload": { @@ -48,7 +48,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Log/composer.json b/src/Log/composer.json index 725373d241a..f605732a31e 100644 --- a/src/Log/composer.json +++ b/src/Log/composer.json @@ -24,7 +24,7 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev", + "cakephp/core": "5.4.*@dev", "psr/log": "^3.0" }, "autoload": { @@ -39,7 +39,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/ORM/composer.json b/src/ORM/composer.json index 966bb65160c..59682698cb9 100644 --- a/src/ORM/composer.json +++ b/src/ORM/composer.json @@ -24,17 +24,17 @@ }, "require": { "php": ">=8.2", - "cakephp/collection": "5.3.*@dev", - "cakephp/core": "5.3.*@dev", - "cakephp/datasource": "5.3.*@dev", - "cakephp/database": "5.3.*@dev", - "cakephp/event": "5.3.*@dev", - "cakephp/utility": "5.3.*@dev", - "cakephp/validation": "5.3.*@dev" + "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.*@dev", - "cakephp/i18n": "5.3.*@dev" + "cakephp/cache": "5.4.*@dev", + "cakephp/i18n": "5.4.*@dev" }, "autoload": { "psr-4": { @@ -52,7 +52,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Utility/composer.json b/src/Utility/composer.json index 0f866567448..b9d56e6e2f8 100644 --- a/src/Utility/composer.json +++ b/src/Utility/composer.json @@ -26,7 +26,7 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev" + "cakephp/core": "5.4.*@dev" }, "autoload": { "psr-4": { @@ -44,7 +44,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } diff --git a/src/Validation/composer.json b/src/Validation/composer.json index 3263185658a..2e91b8f7e50 100644 --- a/src/Validation/composer.json +++ b/src/Validation/composer.json @@ -23,12 +23,12 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "5.3.*@dev", - "cakephp/utility": "5.3.*@dev", + "cakephp/core": "5.4.*@dev", + "cakephp/utility": "5.4.*@dev", "psr/http-message": "^1.1 || ^2.0" }, "require-dev": { - "cakephp/i18n": "5.3.*@dev" + "cakephp/i18n": "5.4.*@dev" }, "autoload": { "psr-4": { @@ -42,7 +42,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } From c12df4f4660384a74f093cd667edc618e27be09d Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 18 Jan 2026 19:10:20 +0530 Subject: [PATCH 025/100] Update branch alias --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3ddbfd1dda2..2bead2815d8 100644 --- a/composer.json +++ b/composer.json @@ -143,7 +143,7 @@ }, "extra": { "branch-alias": { - "dev-5.next": "5.3.x-dev" + "dev-5.next": "5.4.x-dev" } } } From ed09e80cadb21b04828354326530d2f0b1f4c157 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 22 Jan 2026 08:54:49 +0100 Subject: [PATCH 026/100] Merge pull request #19208 from cakephp/5.next-installed-plugins Add `getInstalledPlugins()` method to PluginConfig for performance --- src/Core/PluginConfig.php | 132 ++++++++++--- .../Command/PluginListCommandTest.php | 4 + tests/TestCase/Core/PluginConfigTest.php | 179 ++++++++++++++++++ 3 files changed, 284 insertions(+), 31 deletions(-) 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/tests/TestCase/Command/PluginListCommandTest.php b/tests/TestCase/Command/PluginListCommandTest.php index 67c7d6914aa..659309fe93a 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; /** @@ -54,6 +56,8 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); + Configure::delete('plugins'); + PluginConfig::clearCache(); if (file_exists($this->pluginsListPath)) { unlink($this->pluginsListPath); } diff --git a/tests/TestCase/Core/PluginConfigTest.php b/tests/TestCase/Core/PluginConfigTest.php index 7d9c94d841e..afbf2b06491 100644 --- a/tests/TestCase/Core/PluginConfigTest.php +++ b/tests/TestCase/Core/PluginConfigTest.php @@ -53,6 +53,7 @@ protected function tearDown(): void { parent::tearDown(); Configure::delete('plugins'); + PluginConfig::clearCache(); $this->clearPlugins(); if (file_exists($this->pluginsListPath)) { unlink($this->pluginsListPath); @@ -390,4 +391,182 @@ public function testInvalidComposerJson(): void ], PluginConfig::getAppConfig()); unlink($pathToTestPlugin . 'composer.json'); } + + public function testGetInstalledPluginsSimple(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/other/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + Configure::delete('plugins'); + $result = PluginConfig::getInstalledPlugins(); + + $expected = [ + 'TestPlugin' => [ + 'path' => '/config/path/', + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + 'OtherPlugin' => [ + 'path' => '/other/path/', + 'isLoaded' => false, + ], + ]; + $this->assertSame($expected, $result); + } + + public function testGetInstalledPluginsWithOptions(): void + { + $file = << [ + 'DebugPlugin' => '/debug/path/', + 'CliPlugin' => '/cli/path/', + 'OptionalPlugin' => '/optional/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = << ['onlyDebug' => true], + 'CliPlugin' => ['onlyCli' => true], + 'OptionalPlugin' => ['optional' => true, 'bootstrap' => false], +]; +PHP; + file_put_contents($this->pluginsConfigPath, $config); + + Configure::delete('plugins'); + $result = PluginConfig::getInstalledPlugins(); + + $expected = [ + 'DebugPlugin' => [ + 'path' => '/debug/path/', + 'isLoaded' => true, + 'onlyDebug' => true, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + 'CliPlugin' => [ + 'path' => '/cli/path/', + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => true, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + 'OptionalPlugin' => [ + 'path' => '/optional/path/', + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => true, + 'bootstrap' => false, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + ]; + $this->assertSame($expected, $result); + } + + public function testGetInstalledPluginsNoPluginsConfig(): void + { + $file = << [ + 'TestPlugin' => '/test/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + // Delete plugins.php to test fallback + if (file_exists($this->pluginsConfigPath)) { + unlink($this->pluginsConfigPath); + } + + Configure::delete('plugins'); + $result = PluginConfig::getInstalledPlugins(); + + $expected = [ + 'TestPlugin' => [ + 'path' => '/test/path/', + 'isLoaded' => false, + ], + ]; + $this->assertSame($expected, $result); + } + + public function testGetInstalledPluginsUnknownPlugin(): void + { + $file = << [ + 'TestPlugin' => '/test/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + Configure::delete('plugins'); + $result = PluginConfig::getInstalledPlugins(); + + $this->assertArrayHasKey('TestPlugin', $result); + $this->assertArrayHasKey('UnknownPlugin', $result); + $this->assertTrue($result['TestPlugin']['isLoaded']); + $this->assertFalse($result['UnknownPlugin']['isLoaded']); + $this->assertTrue($result['UnknownPlugin']['isUnknown']); + $this->assertArrayNotHasKey('path', $result['UnknownPlugin']); + } } From feef47aa5af1e14ea9b5d2f32decefd256e696b1 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 4 Feb 2026 19:14:05 +0100 Subject: [PATCH 027/100] Add draft MapRequestDto action mapping --- README.md | 14 +++ src/Controller/Attribute/MapRequestDto.php | 25 +++++ src/Controller/ControllerFactory.php | 101 ++++++++++++++++++ .../Controller/ControllerFactoryTest.php | 24 +++++ .../Controller/DependenciesController.php | 9 ++ tests/test_app/TestApp/Dto/RequestDataDto.php | 31 ++++++ 6 files changed, 204 insertions(+) create mode 100644 src/Controller/Attribute/MapRequestDto.php create mode 100644 tests/test_app/TestApp/Dto/RequestDataDto.php diff --git a/README.md b/README.md index 90514947435..1c5fce7e8b5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,20 @@ Assuming you have PHPUnit installed (`composer install`), you can run the tests a non-SQLite datasource. 3. Run `vendor/bin/phpunit`. +## Controller Request DTO Mapping (Draft) + +You can map request data into value objects using the `#[MapRequestDto]` attribute on action +parameters. The target class only needs a static `createFromArray()` method. + +```php +use Cake\Controller\Attribute\MapRequestDto; + +public function add(#[MapRequestDto] UserDto $dto): void +{ + // $dto is built from request body/query data +} +``` + ## Learn More * [CakePHP](https://cakephp.org) - The home of the CakePHP project. diff --git a/src/Controller/Attribute/MapRequestDto.php b/src/Controller/Attribute/MapRequestDto.php new file mode 100644 index 00000000000..275b56b87b2 --- /dev/null +++ b/src/Controller/Attribute/MapRequestDto.php @@ -0,0 +1,25 @@ +controller->getRequest(); foreach ($function->getParameters() as $parameter) { + $attribute = $this->getMapRequestDtoAttribute($parameter); + if ($attribute !== null) { + $resolved[] = $this->resolveDtoFromRequest($parameter, $attribute, $request); + continue; + } + $type = $parameter->getType(); // Check for dependency injection for classes @@ -269,6 +278,98 @@ protected function getActionArgs(Closure $action, array $passedParams): array return array_merge($resolved, $passedParams); } + /** + * @param \ReflectionParameter $parameter + * @return \Cake\Controller\Attribute\MapRequestDto|null + */ + protected function getMapRequestDtoAttribute(ReflectionParameter $parameter): ?MapRequestDto + { + /** @var array<\ReflectionAttribute<\Cake\Controller\Attribute\MapRequestDto>> $attributes */ + $attributes = $parameter->getAttributes(MapRequestDto::class); + foreach ($attributes as $attribute) { + return $attribute->newInstance(); + } + + return null; + } + + /** + * @param \ReflectionParameter $parameter + * @param \Cake\Controller\Attribute\MapRequestDto $attribute + * @param \Cake\Http\ServerRequest $request + * @return object + */ + protected function resolveDtoFromRequest( + ReflectionParameter $parameter, + MapRequestDto $attribute, + ServerRequest $request, + ): object { + $dtoClass = $attribute->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, + ]); + } + + $data = $this->extractDtoData($request, $attribute->source); + + /** @var class-string $dtoClass */ + return $dtoClass::createFromArray($data); + } + + /** + * @param \Cake\Http\ServerRequest $request + * @param string $source + * @return array + */ + protected function extractDtoData(ServerRequest $request, string $source): array + { + return match ($source) { + MapRequestDto::SOURCE_BODY => (array)$request->getData(), + MapRequestDto::SOURCE_QUERY => $request->getQueryParams(), + MapRequestDto::SOURCE_REQUEST => array_merge( + $request->getQueryParams(), + (array)$request->getData(), + ), + default => $this->extractAutoDtoData($request), + }; + } + + /** + * @param \Cake\Http\ServerRequest $request + * @return array + */ + protected function extractAutoDtoData(ServerRequest $request): array + { + if ($request->is(['get', 'head'])) { + return $request->getQueryParams(); + } + + $data = (array)$request->getData(); + if ($data !== []) { + return $data; + } + + return $request->getQueryParams(); + } + /** * Coerces string argument to primitive type. * diff --git a/tests/TestCase/Controller/ControllerFactoryTest.php b/tests/TestCase/Controller/ControllerFactoryTest.php index dd9aa90620f..f87bdf273cd 100644 --- a/tests/TestCase/Controller/ControllerFactoryTest.php +++ b/tests/TestCase/Controller/ControllerFactoryTest.php @@ -562,6 +562,30 @@ public function testInvokeInjectParametersRequiredNotDefined(): void $this->factory->invoke($controller); } + public function testInvokeMapRequestDtoAttribute(): void + { + $request = new ServerRequest([ + 'url' => 'dependencies/requestDto', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requestDto', + ], + 'post' => [ + 'title' => 'Map Request', + 'count' => '3', + ], + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + + $this->assertSame(['title' => 'Map Request', 'count' => '3'], $data); + } + public function testInvokeInjectParametersRequiredMissingUntyped(): void { $request = new ServerRequest([ diff --git a/tests/test_app/TestApp/Controller/DependenciesController.php b/tests/test_app/TestApp/Controller/DependenciesController.php index 523420d340e..0bf4ecf9f58 100644 --- a/tests/test_app/TestApp/Controller/DependenciesController.php +++ b/tests/test_app/TestApp/Controller/DependenciesController.php @@ -3,10 +3,12 @@ namespace TestApp\Controller; +use Cake\Controller\Attribute\MapRequestDto; use Cake\Controller\Controller; use Cake\Event\EventManagerInterface; use Cake\Http\ServerRequest; use stdClass; +use TestApp\Dto\RequestDataDto; use TestApp\ReflectionDependency; /** @@ -92,6 +94,13 @@ public function requiredDep(stdClass $dep, $any = null, ?string $str = null) return $this->response->withStringBody(json_encode(compact('dep', 'any', 'str'))); } + public function requestDto( + #[MapRequestDto] + RequestDataDto $dto, + ) { + return $this->response->withStringBody(json_encode($dto->toArray())); + } + /** * @return \Cake\Http\Response */ diff --git a/tests/test_app/TestApp/Dto/RequestDataDto.php b/tests/test_app/TestApp/Dto/RequestDataDto.php new file mode 100644 index 00000000000..fe9ede89abc --- /dev/null +++ b/tests/test_app/TestApp/Dto/RequestDataDto.php @@ -0,0 +1,31 @@ + $data + */ + public function __construct(private readonly array $data) + { + } + + /** + * @param array $data + * @return static + */ + public static function createFromArray(array $data): self + { + return new self($data); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->data; + } +} From 69db8e1e927469152cddd968f6c8ab38c421579d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 7 Feb 2026 15:22:54 +0100 Subject: [PATCH 028/100] Add Collection convenience methods: keys(), values(), implode(), when(), unless() (#19134) Add convenience methods to Collection: keys(), values(), implode(), when(), unless() - keys(): Returns collection of keys from the collection - values(): Returns collection of values with consecutive integer keys - implode(): Concatenates elements into a string with optional path extraction - when(): Conditionally applies callback when condition is truthy - unless(): Conditionally applies callback when condition is falsy These methods close some gaps compared to other collection libraries (Laravel, Doctrine) and provide more fluent API options without breaking backward compatibility. --- src/Collection/CollectionInterface.php | 5 + src/Collection/CollectionTrait.php | 65 +++++ tests/TestCase/Collection/CollectionTest.php | 272 +++++++++++++++++++ 3 files changed, 342 insertions(+) diff --git a/src/Collection/CollectionInterface.php b/src/Collection/CollectionInterface.php index a62f581cb7e..c8bf061cdec 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 b913e500672..f1c0e4927e4 100644 --- a/src/Collection/CollectionTrait.php +++ b/src/Collection/CollectionTrait.php @@ -1151,6 +1151,71 @@ public function countKeys(): int return count($this->toArray()); } + /** + * @inheritDoc + */ + public function keys(): CollectionInterface + { + $generator = function (): Generator { + foreach ($this->optimizeUnwrap() as $key => $value) { + yield $key; + } + }; + + return $this->newCollection($generator()); + } + + /** + * @inheritDoc + */ + 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()); + } + + /** + * @inheritDoc + */ + public function when(mixed $condition, callable $callback): CollectionInterface + { + if ($condition) { + return $callback($this, $condition); + } + + return $this; + } + + /** + * @inheritDoc + */ + 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/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); + } } From 666b00f553c76b889cf8a8bb428e7fd083e6be55 Mon Sep 17 00:00:00 2001 From: Joachim Rey Date: Mon, 9 Feb 2026 19:49:23 +0100 Subject: [PATCH 029/100] Merge pull request #19242 from j04chim/5.x Change units of Number::toReadableSize() from KB, etc to KiB, etc --- src/I18n/Number.php | 48 ++++++++++++--- tests/TestCase/I18n/NumberTest.php | 98 ++++++++++++++++++++++++++---- 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/src/I18n/Number.php b/src/I18n/Number.php index a264c871c5d..49d3bfabaa1 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. * diff --git a/tests/TestCase/I18n/NumberTest.php b/tests/TestCase/I18n/NumberTest.php index c27c09665be..3f5d490cd2d 100644 --- a/tests/TestCase/I18n/NumberTest.php +++ b/tests/TestCase/I18n/NumberTest.php @@ -512,53 +512,117 @@ public function testToReadableSize(): void $expected = '45 Bytes'; $this->assertSame($expected, $result); + $result = $this->Number->toReadableSize(1000); + $expected = '1 KB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024); + $expected = '1.02 KB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 + 123); + $expected = '1.15 KB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 512); + $expected = '524.29 KB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 - 1); + $expected = '1.05 MB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(512.05 * 1024 * 1024); + $expected = '536.92 MB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 - 1); + $expected = '1.07 GB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 512); + $expected = '549.76 GB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 - 1); + $expected = '1.1 TB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 512); + $expected = '562.95 TB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 - 1); + $expected = '1,125.9 TB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024); + $expected = '1,152,921.5 TB'; + $this->assertSame($expected, $result); + + $this->Number->setUseIecUnits(true); + + $result = $this->Number->toReadableSize(0); + $expected = '0 Bytes'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1); + $expected = '1 Byte'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(45); + $expected = '45 Bytes'; + $this->assertSame($expected, $result); + $result = $this->Number->toReadableSize(1023); $expected = '1,023 Bytes'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024); - $expected = '1 KB'; + $expected = '1 KiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 + 123); - $expected = '1.12 KB'; + $expected = '1.12 KiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 512); - $expected = '512 KB'; + $expected = '512 KiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 1024 - 1); - $expected = '1 MB'; + $expected = '1 MiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(512.05 * 1024 * 1024); - $expected = '512.05 MB'; + $expected = '512.05 MiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 1024 * 1024 - 1); - $expected = '1 GB'; + $expected = '1 GiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 512); - $expected = '512 GB'; + $expected = '512 GiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 - 1); - $expected = '1 TB'; + $expected = '1 TiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 512); - $expected = '512 TB'; + $expected = '512 TiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 - 1); - $expected = '1,024 TB'; + $expected = '1,024 TiB'; $this->assertSame($expected, $result); $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024); - $expected = '1,048,576 TB'; + $expected = '1,048,576 TiB'; $this->assertSame($expected, $result); + + $this->Number->setUseIecUnits(false); } /** @@ -568,10 +632,18 @@ public function testReadableSizeLocalized(): void { I18n::setLocale('fr_FR'); $result = $this->Number->toReadableSize(1321205); - $this->assertSame('1,26 MB', $result); + $this->assertSame('1,32 MB', $result); + + $result = $this->Number->toReadableSize(512.05 * 1024 * 1024 * 1024); + $this->assertSame('549,81 GB', $result); + + $this->Number->setUseIecUnits(true); + + $result = $this->Number->toReadableSize(1321205); + $this->assertSame('1,26 MiB', $result); $result = $this->Number->toReadableSize(512.05 * 1024 * 1024 * 1024); - $this->assertSame('512,05 GB', $result); + $this->assertSame('512,05 GiB', $result); } /** From cfc0dd14e1dabe8540104c1037c6d138c8fa3dde Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Mon, 9 Feb 2026 20:02:11 +0100 Subject: [PATCH 030/100] 5.next: port over `thephpleague/container` (#18090) * port over thephpleague/container * adjust root composer.json * adjust stan * enable cached auto-wiring by default * adjust for modern PHP features * add Container::addDefinitions method * add ability to add arguments to DIC definition via argname * Fix autowiring issues and CI failures for Container (#19252) * Fix autowiring issues and add make() method for Container - Fix interface-to-implementation binding ignoring existing definitions (addresses league/container#275, #278). When binding an interface to a concrete class that already has a registered definition, the container now correctly uses the existing definition instead of bypassing it. - Add Container::make() method to allow passing constructor arguments during autowiring (addresses league/container#277). This allows: `$container->make(MyService::class, ['configValue' => 'foo'])` - Add Container::hasDefinition() method to check if an explicit definition exists (vs checking if autowiring can resolve it). - Add ReflectionContainer::getNew() to bypass caching when needed. - Fix PHPCS issues (trailing commas in multi-line function calls). - Fix PHPStan type errors with callable and class-string annotations. - Fix PHPUnit 12 compatibility (replace self::returnSelf() with willReturnSelf()). * Fix phpcs: remove unused imports, fix line length * Fix missing BarInterface import in ContainerTest * Fix PHPStan errors: unused parameter in Baz, NonExistent class reference * Apply rector fixes and code style improvements * Fix Container constructor to use non-nullable promoted properties * Fix Container split package: PHP version and source URL * Add addDefinitions() to DefinitionContainerInterface * Update src/Container/composer.json Co-authored-by: ADmad --------- Co-authored-by: Mark Scherer Co-authored-by: mscherer Co-authored-by: ADmad --- composer.json | 6 +- phpstan.neon.dist | 1 + src/Container/Argument/ArgumentInterface.php | 12 + .../Argument/ArgumentResolverInterface.php | 23 ++ .../Argument/ArgumentResolverTrait.php | 120 ++++++ .../Argument/DefaultValueArgument.php | 27 ++ .../Argument/DefaultValueInterface.php | 12 + .../Argument/Literal/ArrayArgument.php | 17 + .../Argument/Literal/BooleanArgument.php | 17 + .../Argument/Literal/CallableArgument.php | 17 + .../Argument/Literal/FloatArgument.php | 17 + .../Argument/Literal/IntegerArgument.php | 17 + .../Argument/Literal/ObjectArgument.php | 17 + .../Argument/Literal/StringArgument.php | 17 + src/Container/Argument/LiteralArgument.php | 51 +++ .../Argument/LiteralArgumentInterface.php | 8 + src/Container/Argument/ResolvableArgument.php | 25 ++ .../Argument/ResolvableArgumentInterface.php | 12 + src/Container/Container.php | 333 ++++++++++++++++ src/Container/ContainerAwareInterface.php | 18 + src/Container/ContainerAwareTrait.php | 45 +++ src/Container/Definition/Definition.php | 336 +++++++++++++++++ .../Definition/DefinitionAggregate.php | 154 ++++++++ .../DefinitionAggregateInterface.php | 69 ++++ .../Definition/DefinitionInterface.php | 90 +++++ .../DefinitionContainerInterface.php | 75 ++++ .../Exception/ContainerException.php | 11 + src/Container/Exception/NotFoundException.php | 11 + src/Container/Inflector/Inflector.php | 121 ++++++ .../Inflector/InflectorAggregate.php | 53 +++ .../Inflector/InflectorAggregateInterface.php | 26 ++ .../Inflector/InflectorInterface.php | 44 +++ src/Container/LICENSE.txt | 21 ++ src/Container/README.md | 19 + src/Container/ReflectionContainer.php | 164 ++++++++ .../AbstractServiceProvider.php | 34 ++ .../BootableServiceProviderInterface.php | 15 + .../ServiceProviderAggregate.php | 88 +++++ .../ServiceProviderAggregateInterface.php | 31 ++ .../ServiceProviderInterface.php | 31 ++ src/Container/composer.json | 45 +++ .../Argument/ArgumentResolverTest.php | 157 ++++++++ .../Container/Argument/TypedArgumentTest.php | 38 ++ tests/TestCase/Container/Asset/Bar.php | 14 + .../TestCase/Container/Asset/BarInterface.php | 8 + tests/TestCase/Container/Asset/Baz.php | 14 + tests/TestCase/Container/Asset/Foo.php | 32 ++ .../TestCase/Container/Asset/FooCallable.php | 12 + tests/TestCase/Container/Asset/ProBar.php | 16 + tests/TestCase/Container/Asset/ProFoo.php | 14 + tests/TestCase/Container/Asset/function.php | 9 + tests/TestCase/Container/ContainerTest.php | 354 ++++++++++++++++++ .../Definition/DefinitionAggregateTest.php | 269 +++++++++++++ .../Container/Definition/DefinitionTest.php | 159 ++++++++ .../Inflector/InflectorAggregateTest.php | 72 ++++ .../Container/Inflector/InflectorTest.php | 143 +++++++ .../Container/ReflectionContainerTest.php | 233 ++++++++++++ .../ServiceProviderAggregateTest.php | 92 +++++ .../ServiceProvider/ServiceProviderTest.php | 45 +++ 59 files changed, 3930 insertions(+), 1 deletion(-) create mode 100644 src/Container/Argument/ArgumentInterface.php create mode 100644 src/Container/Argument/ArgumentResolverInterface.php create mode 100644 src/Container/Argument/ArgumentResolverTrait.php create mode 100644 src/Container/Argument/DefaultValueArgument.php create mode 100644 src/Container/Argument/DefaultValueInterface.php create mode 100644 src/Container/Argument/Literal/ArrayArgument.php create mode 100644 src/Container/Argument/Literal/BooleanArgument.php create mode 100644 src/Container/Argument/Literal/CallableArgument.php create mode 100644 src/Container/Argument/Literal/FloatArgument.php create mode 100644 src/Container/Argument/Literal/IntegerArgument.php create mode 100644 src/Container/Argument/Literal/ObjectArgument.php create mode 100644 src/Container/Argument/Literal/StringArgument.php create mode 100644 src/Container/Argument/LiteralArgument.php create mode 100644 src/Container/Argument/LiteralArgumentInterface.php create mode 100644 src/Container/Argument/ResolvableArgument.php create mode 100644 src/Container/Argument/ResolvableArgumentInterface.php create mode 100644 src/Container/Container.php create mode 100644 src/Container/ContainerAwareInterface.php create mode 100644 src/Container/ContainerAwareTrait.php create mode 100644 src/Container/Definition/Definition.php create mode 100644 src/Container/Definition/DefinitionAggregate.php create mode 100644 src/Container/Definition/DefinitionAggregateInterface.php create mode 100644 src/Container/Definition/DefinitionInterface.php create mode 100644 src/Container/DefinitionContainerInterface.php create mode 100644 src/Container/Exception/ContainerException.php create mode 100644 src/Container/Exception/NotFoundException.php create mode 100644 src/Container/Inflector/Inflector.php create mode 100644 src/Container/Inflector/InflectorAggregate.php create mode 100644 src/Container/Inflector/InflectorAggregateInterface.php create mode 100644 src/Container/Inflector/InflectorInterface.php create mode 100644 src/Container/LICENSE.txt create mode 100644 src/Container/README.md create mode 100644 src/Container/ReflectionContainer.php create mode 100644 src/Container/ServiceProvider/AbstractServiceProvider.php create mode 100644 src/Container/ServiceProvider/BootableServiceProviderInterface.php create mode 100644 src/Container/ServiceProvider/ServiceProviderAggregate.php create mode 100644 src/Container/ServiceProvider/ServiceProviderAggregateInterface.php create mode 100644 src/Container/ServiceProvider/ServiceProviderInterface.php create mode 100644 src/Container/composer.json create mode 100644 tests/TestCase/Container/Argument/ArgumentResolverTest.php create mode 100644 tests/TestCase/Container/Argument/TypedArgumentTest.php create mode 100644 tests/TestCase/Container/Asset/Bar.php create mode 100644 tests/TestCase/Container/Asset/BarInterface.php create mode 100644 tests/TestCase/Container/Asset/Baz.php create mode 100644 tests/TestCase/Container/Asset/Foo.php create mode 100644 tests/TestCase/Container/Asset/FooCallable.php create mode 100644 tests/TestCase/Container/Asset/ProBar.php create mode 100644 tests/TestCase/Container/Asset/ProFoo.php create mode 100644 tests/TestCase/Container/Asset/function.php create mode 100644 tests/TestCase/Container/ContainerTest.php create mode 100644 tests/TestCase/Container/Definition/DefinitionAggregateTest.php create mode 100644 tests/TestCase/Container/Definition/DefinitionTest.php create mode 100644 tests/TestCase/Container/Inflector/InflectorAggregateTest.php create mode 100644 tests/TestCase/Container/Inflector/InflectorTest.php create mode 100644 tests/TestCase/Container/ReflectionContainerTest.php create mode 100644 tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php create mode 100644 tests/TestCase/Container/ServiceProvider/ServiceProviderTest.php diff --git a/composer.json b/composer.json index b029f56f3d1..59cea7e8f7c 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,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", @@ -115,7 +116,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.neon.dist b/phpstan.neon.dist index 1739bcb7344..758d40a3f04 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -20,6 +20,7 @@ parameters: - identifier: method.internalClass - identifier: new.internalClass - identifier: trait.unused + - identifier: method.templateTypeNotInParameter - message: '#^Call to an undefined method PHPUnit\\Framework\\MockObject\\MockBuilder\\:\:addMethods\(\)\.$#' reportUnmatched: false 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..1c9ac637d70 --- /dev/null +++ b/src/Container/Definition/Definition.php @@ -0,0 +1,336 @@ +alias = $id; + $this->concrete = $concrete; + } + + /** + * @inheritDoc + */ + public function addTag(string $tag): DefinitionInterface + { + $this->tags[$tag] = true; + + return $this; + } + + /** + * @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..dd85957b442 --- /dev/null +++ b/src/Container/Definition/DefinitionInterface.php @@ -0,0 +1,90 @@ + 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/tests/TestCase/Container/Argument/ArgumentResolverTest.php b/tests/TestCase/Container/Argument/ArgumentResolverTest.php new file mode 100644 index 00000000000..cdfab904b0d --- /dev/null +++ b/tests/TestCase/Container/Argument/ArgumentResolverTest.php @@ -0,0 +1,157 @@ +shouldReceive('has') + ->with('alias1') + ->andReturn(true); + + $container + ->shouldReceive('get') + ->with('alias1') + ->andReturn($resolver); + + $container + ->shouldReceive('has') + ->with('alias2') + ->andReturn(false); + + /** @var Container $container */ + $resolver->setContainer($container); + + $args = $resolver->resolveArguments(['alias1', 'alias2']); + + $this->assertSame($resolver, $args[0]); + $this->assertSame('alias2', $args[1]); + } + + public function testResolverResolvesLiteralArguments(): void + { + $resolver = new class implements ArgumentResolverInterface { + use ArgumentResolverTrait; + use ContainerAwareTrait; + }; + + $container = $this->getMockBuilder(Container::class)->getMock(); + + $container + ->expects($this->once()) + ->method('has') + ->with(self::equalTo('alias1')) + ->willReturn(true); + + $container + ->expects($this->once()) + ->method('get') + ->with(self::equalTo('alias1')) + ->willReturn(new Literal\StringArgument('value1')); + + /** @var Container $container */ + $resolver->setContainer($container); + + $args = $resolver->resolveArguments(['alias1', new Literal\StringArgument('value2')]); + + self::assertSame('value1', $args[0]); + self::assertSame('value2', $args[1]); + } + + public function testResolverResolvesArgumentsViaReflection(): void + { + $method = $this->getMockBuilder(ReflectionFunctionAbstract::class)->getMock(); + $param1 = $this->getMockBuilder(ReflectionParameter::class)->disableOriginalConstructor()->getMock(); + $param2 = $this->getMockBuilder(ReflectionParameter::class)->disableOriginalConstructor()->getMock(); + $param3 = $this->getMockBuilder(ReflectionParameter::class)->disableOriginalConstructor()->getMock(); + $class = $this->getMockBuilder(ReflectionNamedType::class)->disableOriginalConstructor()->getMock(); + $container = $this->getMockBuilder(Container::class)->getMock(); + + $class->expects(self::once())->method('getName')->willReturn('Class'); + $param1->expects(self::once())->method('getName')->willReturn('param1'); + $param1->expects(self::once())->method('getType')->willReturn($class); + + $param2->expects(self::once())->method('getName')->willReturn('param2'); + $param2->expects(self::once())->method('getType')->willReturn(null); + $param2->expects(self::once())->method('isDefaultValueAvailable')->willReturn(true); + $param2->expects(self::once())->method('getDefaultValue')->willReturn('value2'); + + $param3->expects(self::once())->method('getName')->willReturn('param3'); + + $method->expects(self::once())->method('getParameters')->willReturn([$param1, $param2, $param3]); + + $container->expects(self::once())->method('has')->with($this->equalTo('Class'))->willReturn(true); + $container->expects(self::once())->method('get')->with($this->equalTo('Class'))->willReturn('classObject'); + + $resolver = new class implements ArgumentResolverInterface { + use ArgumentResolverTrait; + use ContainerAwareTrait; + }; + + /** @var Container $container */ + $resolver->setContainer($container); + + $args = $resolver->reflectArguments($method, ['param3' => 'value3']); + + self::assertSame('classObject', $args[0]); + self::assertSame('value2', $args[1]); + self::assertSame('value3', $args[2]); + } + + public function testResolvesDefaultValueArgument(): void + { + $resolver = new class implements ArgumentResolverInterface { + use ArgumentResolverTrait; + use ContainerAwareTrait; + }; + + $result = $resolver->reflectArguments((new ReflectionClass(Baz::class))->getConstructor()); + self::assertSame([null], $result); + } + + public function testResolverThrowsExceptionWhenReflectionDoesNotResolve(): void + { + $this->expectException(NotFoundExceptionInterface::class); + + $method = $this->getMockBuilder(ReflectionFunctionAbstract::class)->getMock(); + $param = $this->getMockBuilder(ReflectionParameter::class)->disableOriginalConstructor()->getMock(); + + $param->expects(self::once())->method('getName')->willReturn('param1'); + $param->expects(self::once())->method('getType')->willReturn(null); + $param->expects(self::once())->method('isDefaultValueAvailable')->willReturn(false); + + $method->expects(self::once())->method('getParameters')->willReturn([$param]); + + $resolver = new class implements ArgumentResolverInterface { + use ArgumentResolverTrait; + use ContainerAwareTrait; + }; + + /** @var ReflectionFunctionAbstract $method */ + $resolver->reflectArguments($method); + } +} diff --git a/tests/TestCase/Container/Argument/TypedArgumentTest.php b/tests/TestCase/Container/Argument/TypedArgumentTest.php new file mode 100644 index 00000000000..69896a1ba2e --- /dev/null +++ b/tests/TestCase/Container/Argument/TypedArgumentTest.php @@ -0,0 +1,38 @@ + [], + Literal\BooleanArgument::class => true, + Literal\CallableArgument::class => function (): void { + }, + Literal\FloatArgument::class => 1.23, + Literal\IntegerArgument::class => 1, + Literal\ObjectArgument::class => new class { + }, + Literal\StringArgument::class => 'string', + ]; + + foreach ($arguments as $type => $expected) { + $argument = new $type($expected); + self::assertSame($expected, $argument->getValue()); + } + } + + public function testLiteralArgumentThrowsWithWrongArgumentType(): void + { + $this->expectException(InvalidArgumentException::class); + new LiteralArgument(LiteralArgument::TYPE_BOOL, 'blah'); + } +} diff --git a/tests/TestCase/Container/Asset/Bar.php b/tests/TestCase/Container/Asset/Bar.php new file mode 100644 index 00000000000..aaf1eb916d0 --- /dev/null +++ b/tests/TestCase/Container/Asset/Bar.php @@ -0,0 +1,14 @@ +something = $something; + } +} diff --git a/tests/TestCase/Container/Asset/BarInterface.php b/tests/TestCase/Container/Asset/BarInterface.php new file mode 100644 index 00000000000..26b9b1eba35 --- /dev/null +++ b/tests/TestCase/Container/Asset/BarInterface.php @@ -0,0 +1,8 @@ +bar = $bar; + } +} diff --git a/tests/TestCase/Container/Asset/Foo.php b/tests/TestCase/Container/Asset/Foo.php new file mode 100644 index 00000000000..adca4fd6182 --- /dev/null +++ b/tests/TestCase/Container/Asset/Foo.php @@ -0,0 +1,32 @@ +bar = $bar; + $this->myString = $myString; + } + + public function setBar(Bar $bar): void + { + $this->bar = $bar; + } + + public static function staticSetBar(Bar $bar, $hello = 'hello world'): void + { + self::$staticHello = $hello; + self::$staticBar = $bar; + } +} diff --git a/tests/TestCase/Container/Asset/FooCallable.php b/tests/TestCase/Container/Asset/FooCallable.php new file mode 100644 index 00000000000..207d564efb2 --- /dev/null +++ b/tests/TestCase/Container/Asset/FooCallable.php @@ -0,0 +1,12 @@ +bar = $bar; + } +} diff --git a/tests/TestCase/Container/Asset/function.php b/tests/TestCase/Container/Asset/function.php new file mode 100644 index 00000000000..2afe3b875e8 --- /dev/null +++ b/tests/TestCase/Container/Asset/function.php @@ -0,0 +1,9 @@ +add(Foo::class); + self::assertTrue($container->has(Foo::class)); + $foo = $container->get(Foo::class); + self::assertInstanceOf(Foo::class, $foo); + } + + public function testContainerAddsAndGetsRecursively(): void + { + $container = new Container(); + $container->add(Bar::class, Foo::class); + $container->add(Foo::class); + self::assertTrue($container->has(Foo::class)); + $foo = $container->get(Bar::class); + self::assertInstanceOf(Foo::class, $foo); + } + + public function testContainerAddsMultipleAndGets(): void + { + $container = new Container(); + $container->addDefinitions([ + Foo::class, + Bar::class, + ]); + self::assertTrue($container->has(Foo::class)); + self::assertTrue($container->has(Bar::class)); + $foo = $container->get(Foo::class); + $bar = $container->get(Bar::class); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $bar); + } + + public function testContainerAddsMultipleWithArgsAndGets(): void + { + $container = new Container(); + $container->addDefinitions([ + Foo::class => [Bar::class], + Bar::class, + ]); + self::assertTrue($container->has(Foo::class)); + self::assertTrue($container->has(Bar::class)); + $foo = $container->get(Foo::class); + $bar = $container->get(Bar::class); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $bar); + self::assertInstanceOf(Bar::class, $foo->bar); + } + + public function testContainerAddsMultipleWithCustomNamesAndGets(): void + { + $container = new Container(); + $container->addDefinitions([ + 'foo' => Foo::class, + 'bar' => Bar::class, + ]); + self::assertTrue($container->has('foo')); + self::assertTrue($container->has('bar')); + $foo = $container->get('foo'); + $bar = $container->get('bar'); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $bar); + } + + public function testContainerAddsAndGetsShared(): void + { + $container = new Container(); + $container->addShared(Foo::class); + self::assertTrue($container->has(Foo::class)); + + $fooOne = $container->get(Foo::class); + $fooTwo = $container->get(Foo::class); + + self::assertInstanceOf(Foo::class, $fooOne); + self::assertInstanceOf(Foo::class, $fooTwo); + self::assertSame($fooOne, $fooTwo); + } + + public function testContainerAddsAndGetsSharedByDefault(): void + { + $container = (new Container())->defaultToShared(); + $container->add(Foo::class); + self::assertTrue($container->has(Foo::class)); + + $fooOne = $container->get(Foo::class); + $fooTwo = $container->get(Foo::class); + + self::assertInstanceOf(Foo::class, $fooOne); + self::assertInstanceOf(Foo::class, $fooTwo); + self::assertSame($fooOne, $fooTwo); + } + + public function testContainerAddsAndGetsFromTag(): void + { + $container = new Container(); + $container->add(Foo::class)->addTag('foobar'); + $container->add(Bar::class)->addTag('foobar'); + self::assertTrue($container->has(Foo::class)); + + $arrayOf = $container->get('foobar'); + + self::assertTrue($container->has('foobar')); + self::assertIsArray($arrayOf); + self::assertCount(2, $arrayOf); + self::assertInstanceOf(Foo::class, $arrayOf[0]); + self::assertInstanceOf(Bar::class, $arrayOf[1]); + } + + public function testContainerAddsAndGetsNewFromTag(): void + { + $container = new Container(); + $container->add(Foo::class)->addTag('foobar'); + $container->add(Bar::class)->addTag('foobar'); + self::assertTrue($container->has(Foo::class)); + + $arrayOf = $container->get('foobar'); + + self::assertTrue($container->has('foobar')); + self::assertIsArray($arrayOf); + self::assertCount(2, $arrayOf); + self::assertInstanceOf(Foo::class, $arrayOf[0]); + self::assertInstanceOf(Bar::class, $arrayOf[1]); + + $arrayOfTwo = $container->getNew('foobar'); + self::assertNotSame($arrayOfTwo, $arrayOf); + } + + public function testContainerAddsAndGetsWithServiceProvider(): void + { + $provider = new class extends AbstractServiceProvider + { + public function provides(string $id): bool + { + return $id === Foo::class; + } + + public function register(): void + { + $this->getContainer()->add(Foo::class); + } + }; + + $container = new Container(); + + $container->addServiceProvider($provider); + self::assertTrue($container->has(Foo::class)); + + $foo = $container->get(Foo::class); + self::assertInstanceOf(Foo::class, $foo); + } + + public function testThrowsWhenServiceProviderLies(): void + { + $liar = new class extends AbstractServiceProvider + { + public function provides(string $id): bool + { + return true; + } + + public function register(): void + { + } + }; + + $container = new Container(); + + $container->addServiceProvider($liar); + self::assertTrue($container->has('lie')); + + $this->expectException(ContainerException::class); + $container->get('lie'); + } + + public function testContainerAddsAndGetsFromDelegate(): void + { + $delegate = new ReflectionContainer(); + $container = new Container(); + $container->delegate($delegate); + $foo = $container->get(Foo::class); + self::assertInstanceOf(Foo::class, $foo); + } + + public function testContainerThrowsWhenCannotGetService(): void + { + $this->expectException(NotFoundException::class); + $container = new Container(); + $container->disableAutoWiring(); + self::assertFalse($container->has(Foo::class)); + $container->get(Foo::class); + } + + public function testContainerCanExtendDefinition(): void + { + $container = new Container(); + $container->add(Foo::class); + $definition = $container->extend(Foo::class); + self::assertSame(Foo::class, $definition->getAlias()); + self::assertSame(Foo::class, $definition->getConcrete()); + } + + public function testContainerCanExtendDefinitionFromServiceProvider(): void + { + $provider = new class extends AbstractServiceProvider + { + public function provides(string $id): bool + { + return $id === Foo::class; + } + + public function register(): void + { + $this->getContainer()->add(Foo::class); + } + }; + + $container = new Container(); + $container->addServiceProvider($provider); + $definition = $container->extend(Foo::class); + self::assertSame(Foo::class, $definition->getAlias()); + self::assertSame(Foo::class, $definition->getConcrete()); + } + + public function testContainerThrowsWhenCannotGetDefinitionToExtend(): void + { + $this->expectException(NotFoundException::class); + $container = new Container(); + $container->disableAutoWiring(); + self::assertFalse($container->has(Foo::class)); + $container->extend(Foo::class); + } + + public function testContainerAddsAndInvokesInflector(): void + { + $container = new Container(); + $container->inflector(Foo::class)->setProperty('bar', Bar::class); + $container->add(Foo::class); + $container->add(Bar::class); + $foo = $container->get(Foo::class); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $foo->bar); + } + + public function testContainerAwareCannotBeUsedWithoutImplementingInterface(): void + { + $this->expectException(BadMethodCallException::class); + + $class = new class { + use ContainerAwareTrait; + }; + + $container = $this->getMockBuilder(Container::class)->getMock(); + $class->setContainer($container); + } + + public function testNonExistentClassCausesException(): void + { + $container = new Container(); + $nonExistent = 'Cake\Test\TestCase\Container\NonExistent'; + $container->add($nonExistent); + + self::assertTrue($container->has($nonExistent)); + self::assertSame($nonExistent, $container->get($nonExistent)); + } + + /** + * Test that named arguments work when all required arguments are provided. + * + * Note: Partial autowiring (where some args are named and others are auto-wired) + * is not yet supported in Definition. For that use case, use Container::make(). + */ + public function testContainerResolvesWithNamedArgument(): void + { + $container = new Container(); + $container->add(Foo::class) + ->addArgument(Bar::class, 'bar') + ->addArgument('something', 'myString'); + self::assertTrue($container->has(Foo::class)); + $foo = $container->get(Foo::class); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $foo->bar); + self::assertSame('something', $foo->myString); + } + + public function testContainerMakeWithArgs(): void + { + $container = new Container(); + $foo = $container->make(Foo::class, ['myString' => 'hello world']); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $foo->bar); + self::assertSame('hello world', $foo->myString); + } + + public function testContainerMakeAlwaysReturnsNewInstance(): void + { + $container = new Container(); + $fooOne = $container->make(Foo::class); + $fooTwo = $container->make(Foo::class); + self::assertNotSame($fooOne, $fooTwo); + } + + public function testInterfaceToImplementationUsesExistingDefinition(): void + { + $container = new Container(); + // Register Bar with specific configuration + $container->addShared(Bar::class); + + // Map interface to the concrete class + $container->add(BarInterface::class, Bar::class); + + // Get via interface should use the existing Bar definition + $barFromInterface = $container->get(BarInterface::class); + $barDirect = $container->get(Bar::class); + + self::assertInstanceOf(Bar::class, $barFromInterface); + // Should be the same instance because Bar is shared + self::assertSame($barDirect, $barFromInterface); + } + + public function testHasDefinitionOnlyChecksExplicitDefinitions(): void + { + $container = new Container(); + $container->add(Foo::class); + + // hasDefinition should return true for explicitly added definitions + self::assertTrue($container->hasDefinition(Foo::class)); + + // hasDefinition should return false for autowirable classes not explicitly added + self::assertFalse($container->hasDefinition(Bar::class)); + + // but has() should return true because autowiring can resolve it + self::assertTrue($container->has(Bar::class)); + } +} diff --git a/tests/TestCase/Container/Definition/DefinitionAggregateTest.php b/tests/TestCase/Container/Definition/DefinitionAggregateTest.php new file mode 100644 index 00000000000..145c7c0d478 --- /dev/null +++ b/tests/TestCase/Container/Definition/DefinitionAggregateTest.php @@ -0,0 +1,269 @@ +getMockBuilder(Container::class)->getMock(); + $definition = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + + $definition + ->expects(self::once()) + ->method('setAlias') + ->with(self::equalTo('alias')) + ->willReturnSelf(); + + $aggregate = (new DefinitionAggregate())->setContainer($container); + $definition = $aggregate->add('alias', $definition); + + self::assertInstanceOf(DefinitionInterface::class, $definition); + } + + public function testAggregateCreatesDefinition(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = (new DefinitionAggregate())->setContainer($container); + $definition = $aggregate->add('alias', Foo::class); + self::assertSame('alias', $definition->getAlias()); + } + + public function testAggregateHasDefinition(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = (new DefinitionAggregate())->setContainer($container); + $aggregate->add('alias', Foo::class); + self::assertTrue($aggregate->has('alias')); + self::assertFalse($aggregate->has('nope')); + } + + public function testAggregateAddsAndIteratesMultipleDefinitions(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = (new DefinitionAggregate())->setContainer($container); + + $definitions = []; + + for ($i = 0; $i < 10; $i++) { + $definitions[] = $aggregate->add('alias' . $i, Foo::class); + } + + foreach ($aggregate->getIterator() as $key => $definition) { + self::assertSame($definitions[$key], $definition); + } + } + + public function testAggregateIteratesAndResolvesDefinition(): void + { + $aggregate = new DefinitionAggregate(); + $definition1 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + $definition2 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + $container = $this->getMockBuilder(Container::class)->getMock(); + + $definition1 + ->expects(self::once()) + ->method('getAlias') + ->willReturn('alias1'); + + $definition1 + ->expects(self::once()) + ->method('setAlias') + ->with(self::equalTo('alias1')) + ->willReturnSelf(); + + $definition2 + ->expects(self::once()) + ->method('getAlias') + ->willReturn('alias2'); + + $definition2 + ->expects(self::once()) + ->method('setContainer') + ->with(self::equalTo($container)) + ->willReturnSelf(); + + $definition2 + ->expects(self::once()) + ->method('setShared') + ->with(self::equalTo(true)) + ->willReturnSelf(); + + $definition2 + ->expects(self::once()) + ->method('setAlias') + ->with(self::equalTo('alias2')) + ->willReturnSelf(); + + $definition2 + ->expects(self::once()) + ->method('resolve') + ->willReturnSelf(); + + $aggregate->setContainer($container); + + $aggregate->add('alias1', $definition1); + $aggregate->addShared('alias2', $definition2); + + $resolved = $aggregate->resolve('alias2'); + self::assertSame($definition2, $resolved); + } + + public function testAggregateCanResolveArrayOfTaggedDefinitions(): void + { + $definition1 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + $definition2 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + $container = $this->getMockBuilder(Container::class)->getMock(); + + $definition1 + ->expects(self::once()) + ->method('setContainer') + ->with(self::equalTo($container)) + ->willReturnSelf(); + + $definition1 + ->expects(self::exactly(2)) + ->method('hasTag') + ->with(self::equalTo('tag')) + ->willReturn(true); + + $definition1 + ->expects(self::once()) + ->method('resolve') + ->willReturn('definition1'); + + $definition2 + ->expects(self::once()) + ->method('setContainer') + ->with(self::equalTo($container)) + ->willReturnSelf(); + + $definition2 + ->expects(self::once()) + ->method('hasTag') + ->with(self::equalTo('tag')) + ->willReturn(true); + + $definition2 + ->expects(self::once()) + ->method('resolve') + ->willReturn('definition2'); + + $aggregate = new DefinitionAggregate([$definition1, $definition2]); + + $aggregate->setContainer($container); + self::assertTrue($aggregate->hasTag('tag')); + $resolved = $aggregate->resolveTagged('tag'); + self::assertSame(['definition1', 'definition2'], $resolved); + } + + public function testAggregateThrowsExceptionWhenCannotResolve(): void + { + $this->expectException(NotFoundException::class); + + $aggregate = new DefinitionAggregate(); + $definition1 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + $definition2 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + $container = $this->getMockBuilder(Container::class)->getMock(); + + $definition1 + ->expects(self::once()) + ->method('getAlias') + ->willReturn('alias1'); + + $definition1 + ->expects(self::once()) + ->method('setAlias') + ->with(self::equalTo('alias1')) + ->willReturnSelf(); + + $definition2 + ->expects(self::once()) + ->method('getAlias') + ->willReturn('alias2'); + + $definition2 + ->expects(self::once()) + ->method('setShared') + ->with(self::equalTo(true)) + ->willReturnSelf(); + + $definition2 + ->expects(self::once()) + ->method('setAlias') + ->with(self::equalTo('alias2')) + ->willReturnSelf(); + + $aggregate->setContainer($container); + + $aggregate->add('alias1', $definition1); + $aggregate->addShared('alias2', $definition2); + + $aggregate->resolveNew('alias'); + } + + public function testDefinitionPrecedingSlash(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = new DefinitionAggregate(); + $aggregate->setContainer($container); + + $some_class = '\\Cake\\Test\\TestCase\\Container\\Asset\\Foo'; + $aggregate->add($some_class, null); + + $definition = $aggregate->getDefinition(Foo::class); + + self::assertInstanceOf(Definition::class, $definition); + } + + public function testGetPrecedingSlash(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = new DefinitionAggregate(); + $aggregate->setContainer($container); + + $some_class = Foo::class; + $aggregate->add($some_class, null); + + $definition = $aggregate->getDefinition('\\Cake\\Test\\TestCase\\Container\\Asset\\Foo'); + + self::assertInstanceOf(Definition::class, $definition); + } + + public function testDefinitionPrecedingSlashSingularQuotes(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = new DefinitionAggregate(); + $aggregate->setContainer($container); + + $some_class = '\\Cake\\Test\\TestCase\\Container\\Asset\\Foo'; + $aggregate->add($some_class, null); + + $definition = $aggregate->getDefinition(Foo::class); + + self::assertInstanceOf(Definition::class, $definition); + } + + public function testGetPrecedingSlashSingularQuote(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = new DefinitionAggregate(); + $aggregate->setContainer($container); + + $some_class = Foo::class; + $aggregate->add($some_class, null); + + $definition = $aggregate->getDefinition('\\Cake\\Test\\TestCase\\Container\\Asset\\Foo'); + + self::assertInstanceOf(Definition::class, $definition); + } +} diff --git a/tests/TestCase/Container/Definition/DefinitionTest.php b/tests/TestCase/Container/Definition/DefinitionTest.php new file mode 100644 index 00000000000..6aceb62c829 --- /dev/null +++ b/tests/TestCase/Container/Definition/DefinitionTest.php @@ -0,0 +1,159 @@ +addArguments(['hello', 'world']); + $actual = $definition->resolve(); + self::assertSame('hello world', $actual); + } + + public function testDefinitionResolvesClosureReturningRawArgument(): void + { + $definition = new Definition('callable', function () { + return new Literal\StringArgument('hello world'); + }); + + $actual = $definition->resolve(); + self::assertSame('hello world', $actual); + } + + public function testDefinitionResolvesCallableClass(): void + { + $definition = new Definition('callable', new FooCallable()); + $definition->addArgument(new Bar()); + $actual = $definition->resolve(); + self::assertInstanceOf(Foo::class, $actual); + } + + public function testDefinitionResolvesArrayCallable(): void + { + $definition = new Definition('callable', [new FooCallable(), '__invoke']); + $definition->addArgument(new Bar()); + $actual = $definition->resolve(); + self::assertInstanceOf(Foo::class, $actual); + } + + public function testDefinitionAddsArgumentWithName(): void + { + $definition = new Definition('class', Foo::class); + $definition->addArgument('something', 'myString'); + $actual = $definition->resolve(); + self::assertInstanceOf(Foo::class, $actual); + self::assertSame('something', $actual->myString); + } + + public function testDefinitionAddsArgumentWithUnknownNameThrows(): void + { + $definition = new Definition('class', Foo::class); + $definition->addArgument('something', 'unknown'); + $this->expectException(Error::class); + $this->expectExceptionMessage('Unknown named parameter $unknown'); + $definition->resolve(); + } + + public function testDefinitionResolvesClassWithMethodCalls(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $bar = new Bar(); + + $container->expects(self::once())->method('has')->with(self::equalTo(Bar::class))->willReturn(true); + $container->expects(self::once())->method('get')->with(self::equalTo(Bar::class))->willReturn($bar); + + $definition = new Definition('callable', Foo::class); + + $definition->setContainer($container); + $definition->addMethodCalls(['setBar' => [Bar::class]]); + + $actual = $definition->resolve(); + self::assertInstanceOf(Foo::class, $actual); + self::assertInstanceOf(Bar::class, $actual->bar); + } + + public function testDefinitionResolvesClassWithDefinedArgs(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $bar = new Bar(); + + $container->expects(self::once())->method('has')->with(self::equalTo(Bar::class))->willReturn(true); + $container->expects(self::once())->method('get')->with(self::equalTo(Bar::class))->willReturn($bar); + + $definition = new Definition('callable', Foo::class); + + $definition->setContainer($container); + $definition->addArgument(Bar::class); + + $actual = $definition->resolve(); + self::assertInstanceOf(Foo::class, $actual); + self::assertInstanceOf(Bar::class, $actual->bar); + } + + public function testDefinitionResolvesSharedItemOnlyOnce(): void + { + $definition = new Definition('class', Foo::class); + $definition->setShared(true); + $actual1 = $definition->resolve(); + $actual2 = $definition->resolve(); + $actual3 = $definition->resolveNew(); + self::assertSame($actual1, $actual2); + self::assertNotSame($actual1, $actual3); + } + + public function testDefinitionResolvesNestedAlias(): void + { + $aliasDefinition = new Definition('alias', new ResolvableArgument('class')); + $definition = new Definition('class', Foo::class); + $container = $this->getMockBuilder(Container::class)->getMock(); + + $expected = $definition->resolve(); + + $container->expects(self::once())->method('has')->with(self::equalTo('class'))->willReturn(true); + $container->expects(self::once())->method('get')->with(self::equalTo('class'))->willReturn($expected); + + $aliasDefinition->setContainer($container); + $actual = $aliasDefinition->resolve(); + self::assertSame($expected, $actual); + } + + public function testDefinitionCanAddTags(): void + { + $definition = new Definition('class', Foo::class); + $definition->addTag('tag1')->addTag('tag2'); + self::assertTrue($definition->hasTag('tag1')); + self::assertTrue($definition->hasTag('tag2')); + self::assertFalse($definition->hasTag('tag3')); + } + + public function testDefinitionCanGetConcrete(): void + { + $concrete = new Literal\StringArgument(Foo::class); + $definition = new Definition('class', $concrete); + self::assertSame($concrete, $definition->getConcrete()); + } + + public function testDefinitionCanSetConcrete(): void + { + $definition = new Definition('class'); + $concrete = new Literal\StringArgument(Foo::class); + $definition->setConcrete($concrete); + self::assertSame($concrete, $definition->getConcrete()); + } +} diff --git a/tests/TestCase/Container/Inflector/InflectorAggregateTest.php b/tests/TestCase/Container/Inflector/InflectorAggregateTest.php new file mode 100644 index 00000000000..0fcac72cb3e --- /dev/null +++ b/tests/TestCase/Container/Inflector/InflectorAggregateTest.php @@ -0,0 +1,72 @@ +add('Some\Type'); + self::assertSame('Some\Type', $inflector->getType()); + } + + public function testAggregateAddsAndIteratesMultipleInflectors(): void + { + $aggregate = new InflectorAggregate(); + $inflectors = []; + + for ($i = 0; $i < 10; $i++) { + $inflectors[] = $aggregate->add('Some\Type' . $i); + } + + foreach ($aggregate->getIterator() as $key => $inflector) { + self::assertSame($inflectors[$key], $inflector); + } + } + + public function testAggregateIteratesAndInflectsOnObject(): void + { + $aggregate = new InflectorAggregate(); + $containerAware = $this->getMockBuilder(ContainerAwareInterface::class)->getMock(); + $container = $this->getMockBuilder(Container::class)->getMock(); + $containerAware->expects(self::once())->method('setContainer')->with(self::equalTo($container)); + $aggregate->add(ContainerAwareInterface::class)->invokeMethod('setContainer', [$container]); + $aggregate->add('Ignored\Type'); + $aggregate->setContainer($container); + $aggregate->inflect($containerAware); + } + + public function testNoInflectionIsAttemptedOnNonObjects(): void + { + $container = new Container(); + + $types = [ + 'my-array' => [1, 2, 3], + 'my-number' => 123, + 'my-string' => 'foo bar', + 'my-generated-array' => [DateTimeZone::class, 'listIdentifiers'], + 'my-generated-number' => 'time', + 'my-generated-string' => function (): string { + return 'blahblahblah'; + }, + ]; + + foreach ($types as $alias => $concrete) { + $container->add($alias, $concrete); + + if (is_callable($concrete)) { + $concrete = $concrete(); + } + + self::assertSame($container->get($alias), $concrete); + } + } +} diff --git a/tests/TestCase/Container/Inflector/InflectorTest.php b/tests/TestCase/Container/Inflector/InflectorTest.php new file mode 100644 index 00000000000..af002dbfdb4 --- /dev/null +++ b/tests/TestCase/Container/Inflector/InflectorTest.php @@ -0,0 +1,143 @@ +getMockBuilder(Container::class)->getMock(); + $inflector = (new Inflector('Type'))->setContainer($container); + + $inflector->invokeMethod('method1', ['arg1']); + + $inflector->invokeMethods([ + 'method2' => ['arg1'], + 'method3' => ['arg1'], + ]); + + $methods = (new ReflectionClass($inflector))->getProperty('methods'); + + self::assertSame($methods->getValue($inflector), [ + 'method1' => ['arg1'], + 'method2' => ['arg1'], + 'method3' => ['arg1'], + ]); + } + + public function testInflectorSetsExpectedProperties(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $inflector = (new Inflector('Type'))->setContainer($container); + + $inflector->setProperty('property1', 'value'); + + $inflector->setProperties([ + 'property2' => 'value', + 'property3' => 'value', + ]); + + $properties = (new ReflectionClass($inflector))->getProperty('properties'); + + self::assertSame($properties->getValue($inflector), [ + 'property1' => 'value', + 'property2' => 'value', + 'property3' => 'value', + ]); + } + + public function testInflectorInflectsWithProperties(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + + $bar = new class { + }; + + $container + ->expects(self::once()) + ->method('has') + ->with(self::equalTo(Bar::class)) + ->willReturn(true); + + $container + ->expects(self::once()) + ->method('get') + ->with(self::equalTo(Bar::class)) + ->willReturn($bar); + + $inflector = (new Inflector('Type')) + ->setContainer($container) + ->setProperty('bar', Bar::class); + + $foo = new class { + public $bar; + }; + + $inflector->inflect($foo); + + self::assertSame($bar, $foo->bar); + } + + public function testInflectorInflectsWithMethodCall(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + + $bar = new class { + }; + + $container + ->expects(self::once()) + ->method('has') + ->with(self::equalTo(Bar::class)) + ->willReturn(true); + + $container + ->expects(self::once()) + ->method('get') + ->with(self::equalTo(Bar::class)) + ->willReturn($bar); + + $inflector = (new Inflector('Type')) + ->setContainer($container) + ->invokeMethod('setBar', [Bar::class]); + + $foo = new class { + public $bar; + public function setBar($bar): void + { + $this->bar = $bar; + } + }; + + $inflector->inflect($foo); + self::assertSame($bar, $foo->bar); + } + + public function testInflectorInflectsWithCallback(): void + { + $foo = new class { + public $bar; + public function setBar($bar): void + { + $this->bar = $bar; + } + }; + + $bar = new class { + }; + + $inflector = new Inflector('Type', function ($object) use ($bar): void { + $object->setBar($bar); + }); + + $inflector->inflect($foo); + self::assertSame($bar, $foo->bar); + } +} diff --git a/tests/TestCase/Container/ReflectionContainerTest.php b/tests/TestCase/Container/ReflectionContainerTest.php new file mode 100644 index 00000000000..4fc524a5573 --- /dev/null +++ b/tests/TestCase/Container/ReflectionContainerTest.php @@ -0,0 +1,233 @@ +getMockBuilder(Container::class)->getMock(); + + $container + ->method('has') + ->willReturnCallback(function ($alias) use ($items) { + return array_key_exists($alias, $items); + }); + + $container + ->method('get') + ->willReturnCallback(function ($alias) use ($items) { + if (array_key_exists($alias, $items)) { + return $items[$alias]; + } + }); + + return $container; + } + + public function testHasReturnsTrueIfClassExists(): void + { + $container = new ReflectionContainer(); + self::assertTrue($container->has(ReflectionContainer::class)); + } + + public function testHasReturnsFalseIfClassDoesNotExist(): void + { + $container = new ReflectionContainer(); + self::assertFalse($container->has('blah')); + } + + public function testContainerInstantiatesClassWithoutConstructor(): void + { + $classWithoutConstructor = stdClass::class; + $container = new ReflectionContainer(); + self::assertInstanceOf($classWithoutConstructor, $container->get($classWithoutConstructor)); + } + + public function testContainerInstantiatesAndCachesClassWithoutConstructor(): void + { + $classWithoutConstructor = stdClass::class; + $container = new ReflectionContainer(true); + + $classWithoutConstructorOne = $container->get($classWithoutConstructor); + $classWithoutConstructorTwo = $container->get($classWithoutConstructor); + + self::assertInstanceOf($classWithoutConstructor, $classWithoutConstructorOne); + self::assertInstanceOf($classWithoutConstructor, $classWithoutConstructorTwo); + self::assertSame($classWithoutConstructorOne, $classWithoutConstructorTwo); + } + + public function testGetInstantiatesClassWithConstructor(): void + { + $classWithConstructor = Foo::class; + $dependencyClass = Bar::class; + + $container = new ReflectionContainer(); + $item = $container->get($classWithConstructor); + + self::assertInstanceOf($classWithConstructor, $item); + self::assertInstanceOf($dependencyClass, $item->bar); + } + + public function testGetInstantiatesAndCachedClassWithConstructor(): void + { + $classWithConstructor = Foo::class; + $dependencyClass = Bar::class; + + $container = new ReflectionContainer(true); + + $itemOne = $container->get($classWithConstructor); + $itemTwo = $container->get($classWithConstructor); + + self::assertInstanceOf($classWithConstructor, $itemOne); + self::assertInstanceOf($dependencyClass, $itemOne->bar); + + self::assertInstanceOf($classWithConstructor, $itemTwo); + self::assertInstanceOf($dependencyClass, $itemTwo->bar); + + self::assertSame($itemOne, $itemTwo); + self::assertSame($itemOne->bar, $itemTwo->bar); + } + + public function testGetInstantiatesClassWithConstructorAndUsesContainer(): void + { + $classWithConstructor = Foo::class; + $dependencyClass = Bar::class; + + $dependency = new $dependencyClass(); + $container = new ReflectionContainer(); + + $container->setContainer($this->getContainerMock([ + $dependencyClass => $dependency, + ])); + + $item = $container->get($classWithConstructor); + + self::assertInstanceOf($classWithConstructor, $item); + self::assertSame($dependency, $item->bar); + } + + public function testGetInstantiatesClassWithConstructorAndUsesArguments(): void + { + $classWithConstructor = Foo::class; + $dependencyClass = Bar::class; + + $dependency = new $dependencyClass(); + $container = new ReflectionContainer(); + + $item = $container->get($classWithConstructor, [ + 'bar' => $dependency, + ]); + + self::assertInstanceOf($classWithConstructor, $item); + self::assertSame($dependency, $item->bar); + } + + public function testThrowsWhenGettingNonExistentClass(): void + { + $this->expectException(NotFoundException::class); + $container = new ReflectionContainer(); + $container->get('Whoooo'); + } + + public function testCallReflectsOnClosureArguments(): void + { + $container = new ReflectionContainer(); + + $foo = $container->call(function (Foo $foo) { + return $foo; + }); + + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $foo->bar); + } + + public function testCallReflectsOnInstanceMethodArguments(): void + { + $container = new ReflectionContainer(); + $foo = new Foo(); + $container->call($foo->setBar(...)); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $foo->bar); + } + + public function testCallReflectsOnStaticMethodArguments(): void + { + $container = new ReflectionContainer(); + $container->call('Cake\Test\TestCase\Container\Asset\Foo::staticSetBar'); + self::assertInstanceOf(Bar::class, Asset\Foo::$staticBar); + self::assertEquals('hello world', Asset\Foo::$staticHello); + } + + public function testCallThrowsWhenArgumentCannotBeResolved(): void + { + $this->expectException(NotFoundException::class); + $container = new ReflectionContainer(); + $container->call([new Bar(), 'setSomething']); + } + + public function testCallResolvesInvokableClass(): void + { + $container = new ReflectionContainer(); + $foo = $container->call(new FooCallable(), [new Bar()]); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $foo->bar); + } + + public function testCallResolvesFunction(): void + { + $container = new ReflectionContainer(); + $foo = $container->call('\Cake\Test\TestCase\Container\Asset\test', [new Bar()]); + self::assertInstanceOf(Foo::class, $foo); + self::assertInstanceOf(Bar::class, $foo->bar); + } + + public function testCallThrowsOnUnknownString(): void + { + $container = new ReflectionContainer(); + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Callable (Unknown) is not a valid callable'); + $container->call('Unknown', [new Bar()]); + } + + public function testGetInstantiatesClassWithConstructorAndSkipsProtectedConstructor(): void + { + $classWithConstructor = ProFoo::class; + + $container = new Container(); + $container->delegate(new ReflectionContainer()); + + $item = $container->get($classWithConstructor); + + $this->assertInstanceOf($classWithConstructor, $item); + $this->assertNull($item->bar); + } + + public function testGetInstantiatesClassWithConstructorAndUsesFactory(): void + { + $classWithConstructor = ProFoo::class; + $dependencyClass = ProBar::class; + + $container = new Container(); + $container->delegate(new ReflectionContainer()); + + $container->add($dependencyClass, [$dependencyClass, 'factory']); + + $item = $container->get($classWithConstructor); + + $this->assertInstanceOf($classWithConstructor, $item); + $this->assertInstanceOf($dependencyClass, $item->bar); + } +} diff --git a/tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php b/tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php new file mode 100644 index 00000000000..b5df5a8957f --- /dev/null +++ b/tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php @@ -0,0 +1,92 @@ +booted++; + } + + public function register(): void + { + $this->registered++; + + $this->getContainer()->add('SomeService', function ($arg) { + return $arg; + }); + } + }; + } + + public function testAggregateAddsClassNameServiceProvider(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = new ServiceProviderAggregate(); + $aggregate->setContainer($container); + $aggregate->add($this->getServiceProvider()); + self::assertTrue($aggregate->provides('SomeService')); + self::assertTrue($aggregate->provides('AnotherService')); + } + + public function testAggregateThrowsWhenRegisteringForServiceThatIsNotAdded(): void + { + $this->expectException(ContainerException::class); + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = (new ServiceProviderAggregate())->setContainer($container); + $aggregate->register('SomeService'); + } + + public function testAggregateInvokesCorrectRegisterMethodOnlyOnce(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = (new ServiceProviderAggregate())->setContainer($container); + $provider = $this->getServiceProvider(); + $aggregate->add($provider); + $aggregate->register('SomeService'); + $aggregate->register('AnotherService'); + self::assertSame(1, $provider->registered); + } + + public function testAggregateSkipsExistingProviders(): void + { + $container = $this->getMockBuilder(Container::class)->getMock(); + $aggregate = (new ServiceProviderAggregate())->setContainer($container); + $provider = $this->getServiceProvider(); + $aggregate->add($provider); + $aggregate->add($provider); + + // assert after adding provider multiple times, that it + // was only aggregated and booted once + self::assertSame( + [$provider], + iterator_to_array($aggregate->getIterator()), + ); + + self::assertSame(1, $provider->booted); + } +} diff --git a/tests/TestCase/Container/ServiceProvider/ServiceProviderTest.php b/tests/TestCase/Container/ServiceProvider/ServiceProviderTest.php new file mode 100644 index 00000000000..8d6c8f43e73 --- /dev/null +++ b/tests/TestCase/Container/ServiceProvider/ServiceProviderTest.php @@ -0,0 +1,45 @@ +getContainer()->add('SomeService', function ($arg) { + return $arg; + }); + } + }; + } + + public function testServiceProviderCorrectlyDeterminesWhatIsProvided(): void + { + $provider = $this->getServiceProvider()->setIdentifier('something'); + self::assertTrue($provider->provides('SomeService')); + self::assertTrue($provider->provides('AnotherService')); + self::assertFalse($provider->provides('NonService')); + } +} From b420a063c69576aa617358d1f5532f7608e49c97 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Mon, 9 Feb 2026 20:10:14 +0100 Subject: [PATCH 031/100] 5.next: port over `thephpleague/container` (#18090) * port over thephpleague/container * adjust root composer.json * adjust stan * enable cached auto-wiring by default * adjust for modern PHP features * add Container::addDefinitions method * add ability to add arguments to DIC definition via argname * Fix autowiring issues and CI failures for Container (#19252) * Fix autowiring issues and add make() method for Container - Fix interface-to-implementation binding ignoring existing definitions (addresses league/container#275, #278). When binding an interface to a concrete class that already has a registered definition, the container now correctly uses the existing definition instead of bypassing it. - Add Container::make() method to allow passing constructor arguments during autowiring (addresses league/container#277). This allows: `$container->make(MyService::class, ['configValue' => 'foo'])` - Add Container::hasDefinition() method to check if an explicit definition exists (vs checking if autowiring can resolve it). - Add ReflectionContainer::getNew() to bypass caching when needed. - Fix PHPCS issues (trailing commas in multi-line function calls). - Fix PHPStan type errors with callable and class-string annotations. - Fix PHPUnit 12 compatibility (replace self::returnSelf() with willReturnSelf()). * Fix phpcs: remove unused imports, fix line length * Fix missing BarInterface import in ContainerTest * Fix PHPStan errors: unused parameter in Baz, NonExistent class reference * Apply rector fixes and code style improvements * Fix Container constructor to use non-nullable promoted properties * Fix Container split package: PHP version and source URL * Add addDefinitions() to DefinitionContainerInterface * Update src/Container/composer.json Co-authored-by: ADmad --------- Co-authored-by: Mark Scherer Co-authored-by: mscherer Co-authored-by: ADmad From 0832571f81d91a22ba9545bb94371823889e3a2a Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 15 Feb 2026 07:49:16 -0600 Subject: [PATCH 032/100] Add PSR-13 Link implementation (#19213) * Add PSR-13 Link implementation. Adds Link and LinkProvider classes implementing PSR-13 EvolvableLinkInterface and EvolvableLinkProviderInterface. - Link: represents a hypermedia link (RFC 5988/8288) - LinkProvider: collects and provides links - Full test coverage * Fix coding standards issues. * Fix PHPDoc type hint order. array|string instead of string|array * Fix @since version to 5.4.0 and add psr/link to http package - Update @since from 5.2.0 to 5.4.0 in Link classes and tests - Add psr/link dependency to src/Http/composer.json - Add psr/link-implementation to provides - Add PSR-13 to description and keywords * Add Response integration and LinkHeaderMiddleware for PSR-13 - Add withLink(), withoutLink(), getLinks(), withLinkProvider() to Response - Add LinkHeaderMiddleware to convert PSR-13 links to HTTP Link headers - Add tests for Response link methods and LinkHeaderMiddleware * Address review feedback: rename $_links to $links, remove unnecessary array_values() * Move Link header emission from middleware to ResponseEmitter Per review feedback, Link headers are now emitted by ResponseEmitter (similar to cookie handling) instead of requiring a separate middleware. Removed LinkHeaderMiddleware and added tests to ResponseEmitterTest. --- composer.json | 2 + src/Http/Link/Link.php | 158 ++++++++++++++++ src/Http/Link/LinkProvider.php | 113 +++++++++++ src/Http/Response.php | 77 ++++++++ src/Http/ResponseEmitter.php | 68 +++++++ src/Http/composer.json | 7 +- tests/TestCase/Http/Link/LinkProviderTest.php | 161 ++++++++++++++++ tests/TestCase/Http/Link/LinkTest.php | 179 ++++++++++++++++++ tests/TestCase/Http/ResponseEmitterTest.php | 144 ++++++++++++++ tests/TestCase/Http/ResponseTest.php | 87 +++++++++ 10 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 src/Http/Link/Link.php create mode 100644 src/Http/Link/LinkProvider.php create mode 100644 tests/TestCase/Http/Link/LinkProviderTest.php create mode 100644 tests/TestCase/Http/Link/LinkTest.php diff --git a/composer.json b/composer.json index 59cea7e8f7c..0f4a8f2aedb 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" }, @@ -76,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" }, 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 d6f8141ce90..83b1f9ef3e4 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. * @@ -1272,6 +1348,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 014d882d02a..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" @@ -35,6 +36,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", "laminas/laminas-diactoros": "^3.8", "laminas/laminas-httphandlerrunner": "^2.6" }, @@ -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/tests/TestCase/Http/Link/LinkProviderTest.php b/tests/TestCase/Http/Link/LinkProviderTest.php new file mode 100644 index 00000000000..6dc67219500 --- /dev/null +++ b/tests/TestCase/Http/Link/LinkProviderTest.php @@ -0,0 +1,161 @@ +assertInstanceOf(LinkProviderInterface::class, $provider); + $this->assertInstanceOf(EvolvableLinkProviderInterface::class, $provider); + } + + /** + * Test getLinks returns empty array by default. + */ + public function testGetLinksEmpty(): void + { + $provider = new LinkProvider(); + $this->assertSame([], iterator_to_array($provider->getLinks())); + } + + /** + * Test getLinks with initial links. + */ + public function testGetLinksWithInitial(): void + { + $link1 = new Link('/api/users', 'self'); + $link2 = new Link('/api/users?page=2', 'next'); + + $provider = new LinkProvider([$link1, $link2]); + $links = iterator_to_array($provider->getLinks()); + + $this->assertCount(2, $links); + $this->assertSame($link1, $links[0]); + $this->assertSame($link2, $links[1]); + } + + /** + * Test getLinksByRel. + */ + public function testGetLinksByRel(): void + { + $link1 = new Link('/api/users', 'self'); + $link2 = new Link('/api/users?page=2', 'next'); + $link3 = new Link('/api', 'self'); + + $provider = new LinkProvider([$link1, $link2, $link3]); + $selfLinks = iterator_to_array($provider->getLinksByRel('self')); + + $this->assertCount(2, $selfLinks); + $this->assertContains($link1, $selfLinks); + $this->assertContains($link3, $selfLinks); + } + + /** + * Test getLinksByRel with no matches. + */ + public function testGetLinksByRelNoMatches(): void + { + $link = new Link('/api/users', 'self'); + $provider = new LinkProvider([$link]); + + $links = iterator_to_array($provider->getLinksByRel('next')); + $this->assertEmpty($links); + } + + /** + * Test withLink adds link. + */ + public function testWithLink(): void + { + $link = new Link('/api/users', 'self'); + $provider = new LinkProvider(); + + $new = $provider->withLink($link); + + $this->assertNotSame($provider, $new); + $this->assertEmpty(iterator_to_array($provider->getLinks())); + + $newLinks = iterator_to_array($new->getLinks()); + $this->assertCount(1, $newLinks); + $this->assertSame($link, $newLinks[0]); + } + + /** + * Test withLink does not duplicate same link instance. + */ + public function testWithLinkNoDuplicates(): void + { + $link = new Link('/api/users', 'self'); + $provider = new LinkProvider([$link]); + + $new = $provider->withLink($link); + + $links = iterator_to_array($new->getLinks()); + $this->assertCount(1, $links); + } + + /** + * Test withoutLink removes link. + */ + public function testWithoutLink(): void + { + $link1 = new Link('/api/users', 'self'); + $link2 = new Link('/api/users?page=2', 'next'); + + $provider = new LinkProvider([$link1, $link2]); + $new = $provider->withoutLink($link1); + + $this->assertNotSame($provider, $new); + + $originalLinks = iterator_to_array($provider->getLinks()); + $this->assertCount(2, $originalLinks); + + $newLinks = iterator_to_array($new->getLinks()); + $this->assertCount(1, $newLinks); + $this->assertSame($link2, $newLinks[0]); + } + + /** + * Test withoutLink with non-existent link. + */ + public function testWithoutLinkNotFound(): void + { + $link1 = new Link('/api/users', 'self'); + $link2 = new Link('/api/users?page=2', 'next'); + + $provider = new LinkProvider([$link1]); + $new = $provider->withoutLink($link2); + + $links = iterator_to_array($new->getLinks()); + $this->assertCount(1, $links); + } +} diff --git a/tests/TestCase/Http/Link/LinkTest.php b/tests/TestCase/Http/Link/LinkTest.php new file mode 100644 index 00000000000..c0609340b3f --- /dev/null +++ b/tests/TestCase/Http/Link/LinkTest.php @@ -0,0 +1,179 @@ +assertInstanceOf(LinkInterface::class, $link); + $this->assertInstanceOf(EvolvableLinkInterface::class, $link); + } + + /** + * Test getHref. + */ + public function testGetHref(): void + { + $link = new Link('/api/users'); + $this->assertSame('/api/users', $link->getHref()); + } + + /** + * Test getRels with string. + */ + public function testGetRelsString(): void + { + $link = new Link('/api/users', 'self'); + $this->assertSame(['self'], $link->getRels()); + } + + /** + * Test getRels with array. + */ + public function testGetRelsArray(): void + { + $link = new Link('/api/users', ['self', 'collection']); + $this->assertSame(['self', 'collection'], $link->getRels()); + } + + /** + * Test getAttributes. + */ + public function testGetAttributes(): void + { + $link = new Link('/api/users', 'self', ['type' => 'application/json']); + $this->assertSame(['type' => 'application/json'], $link->getAttributes()); + } + + /** + * Test isTemplated returns false for non-templated URI. + */ + public function testIsTemplatedFalse(): void + { + $link = new Link('/api/users'); + $this->assertFalse($link->isTemplated()); + } + + /** + * Test isTemplated returns true for templated URI. + */ + public function testIsTemplatedTrue(): void + { + $link = new Link('/api/users/{id}'); + $this->assertTrue($link->isTemplated()); + } + + /** + * Test withHref returns new instance. + */ + public function testWithHref(): void + { + $link = new Link('/api/users'); + $new = $link->withHref('/api/posts'); + + $this->assertNotSame($link, $new); + $this->assertSame('/api/users', $link->getHref()); + $this->assertSame('/api/posts', $new->getHref()); + } + + /** + * Test withRel adds relation. + */ + public function testWithRel(): void + { + $link = new Link('/api/users', 'self'); + $new = $link->withRel('collection'); + + $this->assertNotSame($link, $new); + $this->assertSame(['self'], $link->getRels()); + $this->assertSame(['self', 'collection'], $new->getRels()); + } + + /** + * Test withRel does not duplicate relations. + */ + public function testWithRelNoDuplicates(): void + { + $link = new Link('/api/users', 'self'); + $new = $link->withRel('self'); + + $this->assertSame(['self'], $new->getRels()); + } + + /** + * Test withoutRel removes relation. + */ + public function testWithoutRel(): void + { + $link = new Link('/api/users', ['self', 'collection']); + $new = $link->withoutRel('self'); + + $this->assertNotSame($link, $new); + $this->assertSame(['self', 'collection'], $link->getRels()); + $this->assertSame(['collection'], $new->getRels()); + } + + /** + * Test withAttribute adds attribute. + */ + public function testWithAttribute(): void + { + $link = new Link('/api/users'); + $new = $link->withAttribute('type', 'application/json'); + + $this->assertNotSame($link, $new); + $this->assertSame([], $link->getAttributes()); + $this->assertSame(['type' => 'application/json'], $new->getAttributes()); + } + + /** + * Test withAttribute overwrites existing attribute. + */ + public function testWithAttributeOverwrite(): void + { + $link = new Link('/api/users', [], ['type' => 'text/html']); + $new = $link->withAttribute('type', 'application/json'); + + $this->assertSame(['type' => 'application/json'], $new->getAttributes()); + } + + /** + * Test withoutAttribute removes attribute. + */ + public function testWithoutAttribute(): void + { + $link = new Link('/api/users', [], ['type' => 'application/json', 'title' => 'Users']); + $new = $link->withoutAttribute('type'); + + $this->assertNotSame($link, $new); + $this->assertSame(['type' => 'application/json', 'title' => 'Users'], $link->getAttributes()); + $this->assertSame(['title' => 'Users'], $new->getAttributes()); + } +} diff --git a/tests/TestCase/Http/ResponseEmitterTest.php b/tests/TestCase/Http/ResponseEmitterTest.php index 1c71c9c4464..ee8f0149e57 100644 --- a/tests/TestCase/Http/ResponseEmitterTest.php +++ b/tests/TestCase/Http/ResponseEmitterTest.php @@ -18,6 +18,7 @@ use Cake\Http\CallbackStream; use Cake\Http\Cookie\Cookie; +use Cake\Http\Link\Link; use Cake\Http\Response; use Cake\Http\ResponseEmitter; use Cake\TestSuite\TestCase; @@ -364,4 +365,147 @@ public function testEmitResponseBodyRangeCallbackStream(): void ]; $this->assertEquals($expected, $GLOBALS['mockedHeaders']); } + + /** + * Test emitting Link headers from PSR-13 links. + */ + public function testEmitResponseLinks(): void + { + $response = (new Response()) + ->withHeader('Content-Type', 'text/html') + ->withLink(new Link('/api/users', 'self')) + ->withLink(new Link('/api/users?page=2', 'next')); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + ob_get_clean(); + + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + 'Link: ; rel="self"', + 'Link: ; rel="next"', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test emitting Link headers with attributes. + */ + public function testEmitResponseLinksWithAttributes(): void + { + $link = (new Link('/css/app.css')) + ->withRel('preload') + ->withAttribute('as', 'style') + ->withAttribute('crossorigin', 'anonymous'); + $response = (new Response()) + ->withLink($link); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + ob_get_clean(); + + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html; charset=UTF-8', + 'Link: ; rel="preload"; as="style"; crossorigin="anonymous"', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test emitting Link headers with boolean attributes. + */ + public function testEmitResponseLinksWithBooleanAttributes(): void + { + $link = (new Link('/script.js')) + ->withRel('preload') + ->withAttribute('as', 'script') + ->withAttribute('nopush', true) + ->withAttribute('disabled', false); + $response = (new Response()) + ->withLink($link); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + ob_get_clean(); + + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html; charset=UTF-8', + 'Link: ; rel="preload"; as="script"; nopush', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test emitting Link headers with array attributes. + */ + public function testEmitResponseLinksWithArrayAttributes(): void + { + $link = (new Link('/api/resource')) + ->withRel('self') + ->withAttribute('hreflang', ['en', 'de']); + $response = (new Response()) + ->withLink($link); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + ob_get_clean(); + + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html; charset=UTF-8', + 'Link: ; rel="self"; hreflang="en"; hreflang="de"', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test emitting Link headers escapes special characters. + */ + public function testEmitResponseLinksEscapesValues(): void + { + $link = (new Link('/api/users')) + ->withRel('self') + ->withAttribute('title', 'A "quoted" value'); + $response = (new Response()) + ->withLink($link); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + ob_get_clean(); + + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html; charset=UTF-8', + 'Link: ; rel="self"; title="A \"quoted\" value"', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test no Link headers emitted when no links present. + */ + public function testEmitResponseNoLinks(): void + { + $response = (new Response()) + ->withHeader('Content-Type', 'text/html'); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + ob_get_clean(); + + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } } diff --git a/tests/TestCase/Http/ResponseTest.php b/tests/TestCase/Http/ResponseTest.php index 2ff4f0a4c2c..a45cbac3a77 100644 --- a/tests/TestCase/Http/ResponseTest.php +++ b/tests/TestCase/Http/ResponseTest.php @@ -23,6 +23,8 @@ use Cake\Http\Cookie\CookieCollection; use Cake\Http\CorsBuilder; use Cake\Http\Exception\NotFoundException; +use Cake\Http\Link\Link; +use Cake\Http\Link\LinkProvider; use Cake\Http\Response; use Cake\Http\ServerRequest; use Cake\I18n\DateTime; @@ -1549,6 +1551,90 @@ public function testHasHeader(): void $this->assertFalse($response->hasHeader('accept')); } + /** + * Test withLink adds a link to the response. + */ + public function testWithLink(): void + { + $response = new Response(); + $link = new Link('/api/users', 'self'); + + $new = $response->withLink($link); + + $this->assertNotSame($response, $new); + $this->assertCount(0, iterator_to_array($response->getLinks()->getLinks())); + $this->assertCount(1, iterator_to_array($new->getLinks()->getLinks())); + + $links = iterator_to_array($new->getLinks()->getLinks()); + $this->assertSame('/api/users', $links[0]->getHref()); + $this->assertSame(['self'], $links[0]->getRels()); + } + + /** + * Test withLink can add multiple links. + */ + public function testWithLinkMultiple(): void + { + $response = new Response(); + $self = new Link('/api/users/1', 'self'); + $next = new Link('/api/users?page=2', 'next'); + + $new = $response + ->withLink($self) + ->withLink($next); + + $links = iterator_to_array($new->getLinks()->getLinks()); + $this->assertCount(2, $links); + $this->assertSame('/api/users/1', $links[0]->getHref()); + $this->assertSame('/api/users?page=2', $links[1]->getHref()); + } + + /** + * Test withoutLink removes a link from the response. + */ + public function testWithoutLink(): void + { + $link = new Link('/api/users', 'self'); + $response = (new Response())->withLink($link); + + $new = $response->withoutLink($link); + + $this->assertNotSame($response, $new); + $this->assertCount(1, iterator_to_array($response->getLinks()->getLinks())); + $this->assertCount(0, iterator_to_array($new->getLinks()->getLinks())); + } + + /** + * Test getLinks returns the link provider. + */ + public function testGetLinks(): void + { + $response = new Response(); + $links = $response->getLinks(); + + $this->assertInstanceOf(LinkProvider::class, $links); + $this->assertCount(0, iterator_to_array($links->getLinks())); + } + + /** + * Test withLinkProvider replaces the link provider. + */ + public function testWithLinkProvider(): void + { + $response = new Response(); + $provider = new LinkProvider([ + new Link('/api/users', 'self'), + new Link('/api/users?page=2', 'next'), + ]); + + $new = $response->withLinkProvider($provider); + + $this->assertNotSame($response, $new); + $this->assertCount(0, iterator_to_array($response->getLinks()->getLinks())); + $this->assertSame($provider, $new->getLinks()); + $this->assertCount(2, iterator_to_array($new->getLinks()->getLinks())); + } + /** * Tests __debugInfo */ @@ -1567,6 +1653,7 @@ public function testDebugInfo(): void 'file' => null, 'fileRange' => [], 'cookies' => new CookieCollection(), + 'links' => new LinkProvider(), 'cacheDirectives' => [], 'body' => 'Foo', ]; From 57c7a0da7f86f43c28fe2da8d02da1ca603a66f4 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Sat, 14 Feb 2026 22:31:44 -0500 Subject: [PATCH 033/100] Hydrate $args and $io as BaseCommand properties The run() method already has both objects before calling execute(). Store them as protected properties so any method in a command can access them without threading parameters through every helper. --- src/Console/BaseCommand.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Console/BaseCommand.php b/src/Console/BaseCommand.php index a4edaa3066b..2765ea49a93 100644 --- a/src/Console/BaseCommand.php +++ b/src/Console/BaseCommand.php @@ -62,6 +62,10 @@ abstract class BaseCommand implements CommandInterface, EventDispatcherInterface */ protected string $name = 'cake unknown'; + protected Arguments $args; + + protected ConsoleIo $io; + protected ?CommandFactoryInterface $factory = null; /** @@ -243,6 +247,9 @@ public function run(array $argv, ConsoleIo $io): ?int return static::CODE_ERROR; } + $this->args = $args; + $this->io = $io; + $this->setOutputLevel($args, $io); if ($args->getOption('help')) { From 57076303c2fbbcb9a3028176ed8205f1d3e9b205 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Sat, 14 Feb 2026 22:31:54 -0500 Subject: [PATCH 034/100] Remove redundant PluginAssets property declarations and assignments These properties and manual assignments in execute() are no longer needed now that BaseCommand hydrates $args and $io in run(). --- src/Command/PluginAssetsCopyCommand.php | 3 --- src/Command/PluginAssetsRemoveCommand.php | 3 --- src/Command/PluginAssetsSymlinkCommand.php | 3 --- src/Command/PluginAssetsTrait.php | 16 ---------------- 4 files changed, 25 deletions(-) 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. * From aadca0f5fca262418312a267bc372e7b29a69e0f Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Sun, 15 Feb 2026 17:37:19 +0000 Subject: [PATCH 035/100] Add tests for BaseCommand $args and $io property hydration --- tests/TestCase/Console/BaseCommandTest.php | 158 +++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/TestCase/Console/BaseCommandTest.php diff --git a/tests/TestCase/Console/BaseCommandTest.php b/tests/TestCase/Console/BaseCommandTest.php new file mode 100644 index 00000000000..8e6f15f9926 --- /dev/null +++ b/tests/TestCase/Console/BaseCommandTest.php @@ -0,0 +1,158 @@ +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 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); + } +} From b50ca8120f455aaedeb03fcbacbb172f98bbb5d6 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 17 Feb 2026 09:30:17 -0500 Subject: [PATCH 036/100] Ensure $args and $io are available in initialize() Move $this->io assignment to the top of run() and relocate initialize() to after argument parsing, so both $this->args and $this->io are hydrated before any userland hook is called. This aligns with the lifecycle ordering planned for 6.x. --- src/Console/BaseCommand.php | 9 ++-- tests/TestCase/Console/BaseCommandTest.php | 54 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/Console/BaseCommand.php b/src/Console/BaseCommand.php index 2765ea49a93..e27aa03110a 100644 --- a/src/Console/BaseCommand.php +++ b/src/Console/BaseCommand.php @@ -175,8 +175,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 */ @@ -232,7 +233,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 { @@ -248,9 +249,9 @@ public function run(array $argv, ConsoleIo $io): ?int return static::CODE_ERROR; } $this->args = $args; - $this->io = $io; $this->setOutputLevel($args, $io); + $this->initialize(); if ($args->getOption('help')) { $this->displayHelp($parser, $args, $io); diff --git a/tests/TestCase/Console/BaseCommandTest.php b/tests/TestCase/Console/BaseCommandTest.php index 8e6f15f9926..2a8e8c676e5 100644 --- a/tests/TestCase/Console/BaseCommandTest.php +++ b/tests/TestCase/Console/BaseCommandTest.php @@ -125,6 +125,60 @@ public function execute(Arguments $args, ConsoleIo $io): int $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. */ From 3b73f9ad0ade6c72ced5ef1122dc26835ed2bdfc Mon Sep 17 00:00:00 2001 From: ADmad Date: Fri, 20 Feb 2026 20:29:19 +0530 Subject: [PATCH 037/100] Fix types (#19286) --- src/View/Widget/SelectBoxWidget.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/View/Widget/SelectBoxWidget.php b/src/View/Widget/SelectBoxWidget.php index 92d17382c3e..d2711c64cd7 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; } From 70a6c7c389222e409fff3b158cded0e12ef64ec2 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 21 Feb 2026 08:31:39 +0100 Subject: [PATCH 038/100] fix container deprecations related to mocking --- tests/TestCase/Container/ContainerTest.php | 2 +- .../Definition/DefinitionAggregateTest.php | 175 +++++++++--------- .../Inflector/InflectorAggregateTest.php | 21 ++- .../Container/Inflector/InflectorTest.php | 32 +--- .../Container/ReflectionContainerTest.php | 24 +-- .../ServiceProviderAggregateTest.php | 8 +- 6 files changed, 124 insertions(+), 138 deletions(-) diff --git a/tests/TestCase/Container/ContainerTest.php b/tests/TestCase/Container/ContainerTest.php index 9e5b2750f6a..da578c63459 100644 --- a/tests/TestCase/Container/ContainerTest.php +++ b/tests/TestCase/Container/ContainerTest.php @@ -269,7 +269,7 @@ public function testContainerAwareCannotBeUsedWithoutImplementingInterface(): vo use ContainerAwareTrait; }; - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $class->setContainer($container); } diff --git a/tests/TestCase/Container/Definition/DefinitionAggregateTest.php b/tests/TestCase/Container/Definition/DefinitionAggregateTest.php index 145c7c0d478..09c14193d86 100644 --- a/tests/TestCase/Container/Definition/DefinitionAggregateTest.php +++ b/tests/TestCase/Container/Definition/DefinitionAggregateTest.php @@ -9,20 +9,21 @@ use Cake\Container\Definition\DefinitionInterface; use Cake\Container\Exception\NotFoundException; use Cake\Test\TestCase\Container\Asset\Foo; +use Mockery; use PHPUnit\Framework\TestCase; class DefinitionAggregateTest extends TestCase { public function testAggregateAddsDefinition(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); - $definition = $this->getMockBuilder(DefinitionInterface::class)->getMock(); + $container = new Container(); + $definition = Mockery::mock(DefinitionInterface::class); $definition - ->expects(self::once()) - ->method('setAlias') - ->with(self::equalTo('alias')) - ->willReturnSelf(); + ->shouldReceive('setAlias') + ->once() + ->with('alias') + ->andReturnSelf(); $aggregate = (new DefinitionAggregate())->setContainer($container); $definition = $aggregate->add('alias', $definition); @@ -32,7 +33,7 @@ public function testAggregateAddsDefinition(): void public function testAggregateCreatesDefinition(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = (new DefinitionAggregate())->setContainer($container); $definition = $aggregate->add('alias', Foo::class); self::assertSame('alias', $definition->getAlias()); @@ -40,7 +41,7 @@ public function testAggregateCreatesDefinition(): void public function testAggregateHasDefinition(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = (new DefinitionAggregate())->setContainer($container); $aggregate->add('alias', Foo::class); self::assertTrue($aggregate->has('alias')); @@ -49,7 +50,7 @@ public function testAggregateHasDefinition(): void public function testAggregateAddsAndIteratesMultipleDefinitions(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = (new DefinitionAggregate())->setContainer($container); $definitions = []; @@ -66,48 +67,48 @@ public function testAggregateAddsAndIteratesMultipleDefinitions(): void public function testAggregateIteratesAndResolvesDefinition(): void { $aggregate = new DefinitionAggregate(); - $definition1 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); - $definition2 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); - $container = $this->getMockBuilder(Container::class)->getMock(); + $definition1 = Mockery::mock(DefinitionInterface::class); + $definition2 = Mockery::mock(DefinitionInterface::class); + $container = new Container(); $definition1 - ->expects(self::once()) - ->method('getAlias') - ->willReturn('alias1'); + ->shouldReceive('getAlias') + ->once() + ->andReturn('alias1'); $definition1 - ->expects(self::once()) - ->method('setAlias') - ->with(self::equalTo('alias1')) - ->willReturnSelf(); + ->shouldReceive('setAlias') + ->once() + ->with('alias1') + ->andReturnSelf(); $definition2 - ->expects(self::once()) - ->method('getAlias') - ->willReturn('alias2'); + ->shouldReceive('getAlias') + ->once() + ->andReturn('alias2'); $definition2 - ->expects(self::once()) - ->method('setContainer') - ->with(self::equalTo($container)) - ->willReturnSelf(); + ->shouldReceive('setContainer') + ->once() + ->with($container) + ->andReturnSelf(); $definition2 - ->expects(self::once()) - ->method('setShared') - ->with(self::equalTo(true)) - ->willReturnSelf(); + ->shouldReceive('setShared') + ->once() + ->with(true) + ->andReturnSelf(); $definition2 - ->expects(self::once()) - ->method('setAlias') - ->with(self::equalTo('alias2')) - ->willReturnSelf(); + ->shouldReceive('setAlias') + ->once() + ->with('alias2') + ->andReturnSelf(); $definition2 - ->expects(self::once()) - ->method('resolve') - ->willReturnSelf(); + ->shouldReceive('resolve') + ->once() + ->andReturnSelf(); $aggregate->setContainer($container); @@ -120,43 +121,43 @@ public function testAggregateIteratesAndResolvesDefinition(): void public function testAggregateCanResolveArrayOfTaggedDefinitions(): void { - $definition1 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); - $definition2 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); - $container = $this->getMockBuilder(Container::class)->getMock(); + $definition1 = Mockery::mock(DefinitionInterface::class); + $definition2 = Mockery::mock(DefinitionInterface::class); + $container = new Container(); $definition1 - ->expects(self::once()) - ->method('setContainer') - ->with(self::equalTo($container)) - ->willReturnSelf(); + ->shouldReceive('setContainer') + ->once() + ->with($container) + ->andReturnSelf(); $definition1 - ->expects(self::exactly(2)) - ->method('hasTag') - ->with(self::equalTo('tag')) - ->willReturn(true); + ->shouldReceive('hasTag') + ->twice() + ->with('tag') + ->andReturn(true); $definition1 - ->expects(self::once()) - ->method('resolve') - ->willReturn('definition1'); + ->shouldReceive('resolve') + ->once() + ->andReturn('definition1'); $definition2 - ->expects(self::once()) - ->method('setContainer') - ->with(self::equalTo($container)) - ->willReturnSelf(); + ->shouldReceive('setContainer') + ->once() + ->with($container) + ->andReturnSelf(); $definition2 - ->expects(self::once()) - ->method('hasTag') - ->with(self::equalTo('tag')) - ->willReturn(true); + ->shouldReceive('hasTag') + ->once() + ->with('tag') + ->andReturn(true); $definition2 - ->expects(self::once()) - ->method('resolve') - ->willReturn('definition2'); + ->shouldReceive('resolve') + ->once() + ->andReturn('definition2'); $aggregate = new DefinitionAggregate([$definition1, $definition2]); @@ -171,37 +172,37 @@ public function testAggregateThrowsExceptionWhenCannotResolve(): void $this->expectException(NotFoundException::class); $aggregate = new DefinitionAggregate(); - $definition1 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); - $definition2 = $this->getMockBuilder(DefinitionInterface::class)->getMock(); - $container = $this->getMockBuilder(Container::class)->getMock(); + $definition1 = Mockery::mock(DefinitionInterface::class); + $definition2 = Mockery::mock(DefinitionInterface::class); + $container = new Container(); $definition1 - ->expects(self::once()) - ->method('getAlias') - ->willReturn('alias1'); + ->shouldReceive('getAlias') + ->once() + ->andReturn('alias1'); $definition1 - ->expects(self::once()) - ->method('setAlias') - ->with(self::equalTo('alias1')) - ->willReturnSelf(); + ->shouldReceive('setAlias') + ->once() + ->with('alias1') + ->andReturnSelf(); $definition2 - ->expects(self::once()) - ->method('getAlias') - ->willReturn('alias2'); + ->shouldReceive('getAlias') + ->once() + ->andReturn('alias2'); $definition2 - ->expects(self::once()) - ->method('setShared') - ->with(self::equalTo(true)) - ->willReturnSelf(); + ->shouldReceive('setShared') + ->once() + ->with(true) + ->andReturnSelf(); $definition2 - ->expects(self::once()) - ->method('setAlias') - ->with(self::equalTo('alias2')) - ->willReturnSelf(); + ->shouldReceive('setAlias') + ->once() + ->with('alias2') + ->andReturnSelf(); $aggregate->setContainer($container); @@ -213,7 +214,7 @@ public function testAggregateThrowsExceptionWhenCannotResolve(): void public function testDefinitionPrecedingSlash(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = new DefinitionAggregate(); $aggregate->setContainer($container); @@ -227,7 +228,7 @@ public function testDefinitionPrecedingSlash(): void public function testGetPrecedingSlash(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = new DefinitionAggregate(); $aggregate->setContainer($container); @@ -241,7 +242,7 @@ public function testGetPrecedingSlash(): void public function testDefinitionPrecedingSlashSingularQuotes(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = new DefinitionAggregate(); $aggregate->setContainer($container); @@ -255,7 +256,7 @@ public function testDefinitionPrecedingSlashSingularQuotes(): void public function testGetPrecedingSlashSingularQuote(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = new DefinitionAggregate(); $aggregate->setContainer($container); diff --git a/tests/TestCase/Container/Inflector/InflectorAggregateTest.php b/tests/TestCase/Container/Inflector/InflectorAggregateTest.php index 0fcac72cb3e..438536b0282 100644 --- a/tests/TestCase/Container/Inflector/InflectorAggregateTest.php +++ b/tests/TestCase/Container/Inflector/InflectorAggregateTest.php @@ -5,6 +5,7 @@ use Cake\Container\Container; use Cake\Container\ContainerAwareInterface; +use Cake\Container\DefinitionContainerInterface; use Cake\Container\Inflector\InflectorAggregate; use DateTimeZone; use PHPUnit\Framework\TestCase; @@ -35,13 +36,27 @@ public function testAggregateAddsAndIteratesMultipleInflectors(): void public function testAggregateIteratesAndInflectsOnObject(): void { $aggregate = new InflectorAggregate(); - $containerAware = $this->getMockBuilder(ContainerAwareInterface::class)->getMock(); - $container = $this->getMockBuilder(Container::class)->getMock(); - $containerAware->expects(self::once())->method('setContainer')->with(self::equalTo($container)); + $containerAware = new class implements ContainerAwareInterface { + public ?DefinitionContainerInterface $container = null; + + public function getContainer(): DefinitionContainerInterface + { + return $this->container; + } + + public function setContainer(DefinitionContainerInterface $container): ContainerAwareInterface + { + $this->container = $container; + + return $this; + } + }; + $container = new Container(); $aggregate->add(ContainerAwareInterface::class)->invokeMethod('setContainer', [$container]); $aggregate->add('Ignored\Type'); $aggregate->setContainer($container); $aggregate->inflect($containerAware); + self::assertSame($container, $containerAware->container); } public function testNoInflectionIsAttemptedOnNonObjects(): void diff --git a/tests/TestCase/Container/Inflector/InflectorTest.php b/tests/TestCase/Container/Inflector/InflectorTest.php index af002dbfdb4..1b70573fd29 100644 --- a/tests/TestCase/Container/Inflector/InflectorTest.php +++ b/tests/TestCase/Container/Inflector/InflectorTest.php @@ -13,7 +13,7 @@ class InflectorTest extends TestCase { public function testInflectorSetsExpectedMethodCalls(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $inflector = (new Inflector('Type'))->setContainer($container); $inflector->invokeMethod('method1', ['arg1']); @@ -34,7 +34,7 @@ public function testInflectorSetsExpectedMethodCalls(): void public function testInflectorSetsExpectedProperties(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $inflector = (new Inflector('Type'))->setContainer($container); $inflector->setProperty('property1', 'value'); @@ -55,22 +55,12 @@ public function testInflectorSetsExpectedProperties(): void public function testInflectorInflectsWithProperties(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $bar = new class { }; - $container - ->expects(self::once()) - ->method('has') - ->with(self::equalTo(Bar::class)) - ->willReturn(true); - - $container - ->expects(self::once()) - ->method('get') - ->with(self::equalTo(Bar::class)) - ->willReturn($bar); + $container->add(Bar::class, $bar); $inflector = (new Inflector('Type')) ->setContainer($container) @@ -87,22 +77,12 @@ public function testInflectorInflectsWithProperties(): void public function testInflectorInflectsWithMethodCall(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $bar = new class { }; - $container - ->expects(self::once()) - ->method('has') - ->with(self::equalTo(Bar::class)) - ->willReturn(true); - - $container - ->expects(self::once()) - ->method('get') - ->with(self::equalTo(Bar::class)) - ->willReturn($bar); + $container->add(Bar::class, $bar); $inflector = (new Inflector('Type')) ->setContainer($container) diff --git a/tests/TestCase/Container/ReflectionContainerTest.php b/tests/TestCase/Container/ReflectionContainerTest.php index 4fc524a5573..c9f7a00a2e5 100644 --- a/tests/TestCase/Container/ReflectionContainerTest.php +++ b/tests/TestCase/Container/ReflectionContainerTest.php @@ -16,23 +16,13 @@ class ReflectionContainerTest extends TestCase { - private function getContainerMock(array $items = []): Container + private function getContainer(array $items = []): Container { - $container = $this->getMockBuilder(Container::class)->getMock(); - - $container - ->method('has') - ->willReturnCallback(function ($alias) use ($items) { - return array_key_exists($alias, $items); - }); - - $container - ->method('get') - ->willReturnCallback(function ($alias) use ($items) { - if (array_key_exists($alias, $items)) { - return $items[$alias]; - } - }); + $container = new Container(); + $container->disableAutoWiring(); + foreach ($items as $alias => $item) { + $container->addShared($alias, $item); + } return $container; } @@ -109,7 +99,7 @@ public function testGetInstantiatesClassWithConstructorAndUsesContainer(): void $dependency = new $dependencyClass(); $container = new ReflectionContainer(); - $container->setContainer($this->getContainerMock([ + $container->setContainer($this->getContainer([ $dependencyClass => $dependency, ])); diff --git a/tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php b/tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php index b5df5a8957f..dafbf1b92a2 100644 --- a/tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php +++ b/tests/TestCase/Container/ServiceProvider/ServiceProviderAggregateTest.php @@ -45,7 +45,7 @@ public function register(): void public function testAggregateAddsClassNameServiceProvider(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = new ServiceProviderAggregate(); $aggregate->setContainer($container); $aggregate->add($this->getServiceProvider()); @@ -56,14 +56,14 @@ public function testAggregateAddsClassNameServiceProvider(): void public function testAggregateThrowsWhenRegisteringForServiceThatIsNotAdded(): void { $this->expectException(ContainerException::class); - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = (new ServiceProviderAggregate())->setContainer($container); $aggregate->register('SomeService'); } public function testAggregateInvokesCorrectRegisterMethodOnlyOnce(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = (new ServiceProviderAggregate())->setContainer($container); $provider = $this->getServiceProvider(); $aggregate->add($provider); @@ -74,7 +74,7 @@ public function testAggregateInvokesCorrectRegisterMethodOnlyOnce(): void public function testAggregateSkipsExistingProviders(): void { - $container = $this->getMockBuilder(Container::class)->getMock(); + $container = new Container(); $aggregate = (new ServiceProviderAggregate())->setContainer($container); $provider = $this->getServiceProvider(); $aggregate->add($provider); From b4308f4ec915e0d32d0e6232df4659021e34b554 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 24 Feb 2026 15:17:56 +0100 Subject: [PATCH 039/100] Address review feedback - Use RequestDtoSource enum instead of string constants - Revert README changes (documentation is out of scope for framework README) --- README.md | 14 -------------- src/Controller/Attribute/MapRequestDto.php | 9 ++------- src/Controller/Attribute/RequestDtoSource.php | 15 +++++++++++++++ src/Controller/ControllerFactory.php | 13 +++++++------ 4 files changed, 24 insertions(+), 27 deletions(-) create mode 100644 src/Controller/Attribute/RequestDtoSource.php diff --git a/README.md b/README.md index 1c5fce7e8b5..90514947435 100644 --- a/README.md +++ b/README.md @@ -52,20 +52,6 @@ Assuming you have PHPUnit installed (`composer install`), you can run the tests a non-SQLite datasource. 3. Run `vendor/bin/phpunit`. -## Controller Request DTO Mapping (Draft) - -You can map request data into value objects using the `#[MapRequestDto]` attribute on action -parameters. The target class only needs a static `createFromArray()` method. - -```php -use Cake\Controller\Attribute\MapRequestDto; - -public function add(#[MapRequestDto] UserDto $dto): void -{ - // $dto is built from request body/query data -} -``` - ## Learn More * [CakePHP](https://cakephp.org) - The home of the CakePHP project. diff --git a/src/Controller/Attribute/MapRequestDto.php b/src/Controller/Attribute/MapRequestDto.php index 275b56b87b2..54b5f886293 100644 --- a/src/Controller/Attribute/MapRequestDto.php +++ b/src/Controller/Attribute/MapRequestDto.php @@ -8,18 +8,13 @@ #[Attribute(Attribute::TARGET_PARAMETER)] class MapRequestDto { - public const SOURCE_BODY = 'body'; - public const SOURCE_QUERY = 'query'; - public const SOURCE_REQUEST = 'request'; - public const SOURCE_AUTO = 'auto'; - /** * @param string|null $class DTO class name (optional for typed parameters) - * @param string $source Data source: body, query, request, or auto + * @param \Cake\Controller\Attribute\RequestDtoSource $source Data source: body, query, request, or auto */ public function __construct( public readonly ?string $class = null, - public readonly string $source = self::SOURCE_AUTO, + public readonly RequestDtoSource $source = RequestDtoSource::Auto, ) { } } diff --git a/src/Controller/Attribute/RequestDtoSource.php b/src/Controller/Attribute/RequestDtoSource.php new file mode 100644 index 00000000000..2c55383186f --- /dev/null +++ b/src/Controller/Attribute/RequestDtoSource.php @@ -0,0 +1,15 @@ + */ - protected function extractDtoData(ServerRequest $request, string $source): array + protected function extractDtoData(ServerRequest $request, RequestDtoSource $source): array { return match ($source) { - MapRequestDto::SOURCE_BODY => (array)$request->getData(), - MapRequestDto::SOURCE_QUERY => $request->getQueryParams(), - MapRequestDto::SOURCE_REQUEST => array_merge( + RequestDtoSource::Body => (array)$request->getData(), + RequestDtoSource::Query => $request->getQueryParams(), + RequestDtoSource::Request => array_merge( $request->getQueryParams(), (array)$request->getData(), ), - default => $this->extractAutoDtoData($request), + RequestDtoSource::Auto => $this->extractAutoDtoData($request), }; } From 7288122e90008bedfa70b9e4a3bbc38377f10505 Mon Sep 17 00:00:00 2001 From: ADmad Date: Wed, 25 Feb 2026 12:44:01 +0530 Subject: [PATCH 040/100] Fix phpstan errors --- src/Collection/CollectionInterface.php | 8 ++++---- src/Collection/CollectionTrait.php | 16 ++++++++++++---- .../Fs/Iterator/CallbackFilterIterator.php | 2 ++ .../Fs/Iterator/ContainsPathFilterIterator.php | 4 ++-- src/Utility/Fs/Iterator/DepthFilterIterator.php | 2 ++ .../Iterator/ExcludeDirectoryFilterIterator.php | 4 ++-- .../Fs/Iterator/FileTypeFilterIterator.php | 1 + .../Fs/Iterator/FilenameFilterIterator.php | 2 ++ src/Utility/Fs/Iterator/GlobFilterIterator.php | 2 ++ .../Fs/Iterator/HiddenFileFilterIterator.php | 2 +- 10 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/Collection/CollectionInterface.php b/src/Collection/CollectionInterface.php index b0d738d7202..1575d8c61c9 100644 --- a/src/Collection/CollectionInterface.php +++ b/src/Collection/CollectionInterface.php @@ -26,11 +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 \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. + * @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 8525ac9d801..5e1620e444a 100644 --- a/src/Collection/CollectionTrait.php +++ b/src/Collection/CollectionTrait.php @@ -1226,7 +1226,9 @@ public function countKeys(): int } /** - * @inheritDoc + * Returns a new collection containing only the keys of the elements. + * + * @return \Cake\Collection\CollectionInterface */ public function keys(): CollectionInterface { @@ -1240,7 +1242,9 @@ public function keys(): CollectionInterface } /** - * @inheritDoc + * Returns a new collection containing only the values, re-indexed with consecutive integers. + * + * @return \Cake\Collection\CollectionInterface */ public function values(): CollectionInterface { @@ -1267,7 +1271,9 @@ public function implode(string $glue, callable|string|null $path = null): string } /** - * @inheritDoc + * Applies callback if condition is truthy. + * + * @return \Cake\Collection\CollectionInterface */ public function when(mixed $condition, callable $callback): CollectionInterface { @@ -1279,7 +1285,9 @@ public function when(mixed $condition, callable $callback): CollectionInterface } /** - * @inheritDoc + * Applies callback if condition is falsy. + * + * @return \Cake\Collection\CollectionInterface */ public function unless(mixed $condition, callable $callback): CollectionInterface { diff --git a/src/Utility/Fs/Iterator/CallbackFilterIterator.php b/src/Utility/Fs/Iterator/CallbackFilterIterator.php index 152477c5ecd..f9a7a6889d7 100644 --- a/src/Utility/Fs/Iterator/CallbackFilterIterator.php +++ b/src/Utility/Fs/Iterator/CallbackFilterIterator.php @@ -23,6 +23,8 @@ /** * Filters files using a custom callback function. + * + * @extends \FilterIterator> */ final class CallbackFilterIterator extends FilterIterator { diff --git a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php index 3f0a914bc04..2c89004aac5 100644 --- a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php +++ b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php @@ -51,7 +51,7 @@ final class ContainsPathFilterIterator extends RecursiveFilterIterator protected array $regexPatterns; /** - * @param \RecursiveIterator $iterator The iterator to filter + * @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) */ @@ -128,7 +128,7 @@ public function accept(): bool */ public function getChildren(): self { - /** @var \RecursiveIterator $inner */ + /** @var \RecursiveIterator $inner */ $inner = $this->getInnerIterator(); // Pass all original patterns through (constructor will separate them again) diff --git a/src/Utility/Fs/Iterator/DepthFilterIterator.php b/src/Utility/Fs/Iterator/DepthFilterIterator.php index 074d96c0660..4c1f796bb42 100644 --- a/src/Utility/Fs/Iterator/DepthFilterIterator.php +++ b/src/Utility/Fs/Iterator/DepthFilterIterator.php @@ -31,6 +31,8 @@ * - LESS_THAN_OR_EQUAL: Depth must be <= value * - GREATER_THAN: Depth must be greater than value * - GREATER_THAN_OR_EQUAL: Depth must be >= value + * + * @extends \FilterIterator> */ final class DepthFilterIterator extends FilterIterator { diff --git a/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php index 5577d364b1a..d7d7cb4f540 100644 --- a/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php +++ b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php @@ -32,7 +32,7 @@ final class ExcludeDirectoryFilterIterator extends RecursiveFilterIterator /** * Constructor. * - * @param \RecursiveIterator $iterator The iterator to filter + * @param \RecursiveIterator $iterator The iterator to filter * @param array $excludeDirs Array of directory names to exclude */ public function __construct( @@ -71,7 +71,7 @@ public function accept(): bool */ public function getChildren(): self { - /** @var \RecursiveIterator $inner */ + /** @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 index ec174ae0d3a..1802675173b 100644 --- a/src/Utility/Fs/Iterator/FileTypeFilterIterator.php +++ b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php @@ -24,6 +24,7 @@ * Filters files by type (files only, directories only, or all). * * @internal + * @extends \FilterIterator> */ class FileTypeFilterIterator extends FilterIterator { diff --git a/src/Utility/Fs/Iterator/FilenameFilterIterator.php b/src/Utility/Fs/Iterator/FilenameFilterIterator.php index 2f50bd2cf07..cd0c0450b09 100644 --- a/src/Utility/Fs/Iterator/FilenameFilterIterator.php +++ b/src/Utility/Fs/Iterator/FilenameFilterIterator.php @@ -31,6 +31,8 @@ * Can be used to include or exclude files based on the $negate parameter: * - When $negate is false (default): includes files matching patterns * - When $negate is true: excludes files matching patterns + * + * @extends \FilterIterator> */ final class FilenameFilterIterator extends FilterIterator { diff --git a/src/Utility/Fs/Iterator/GlobFilterIterator.php b/src/Utility/Fs/Iterator/GlobFilterIterator.php index 8a186d6566b..61d3c59f2f7 100644 --- a/src/Utility/Fs/Iterator/GlobFilterIterator.php +++ b/src/Utility/Fs/Iterator/GlobFilterIterator.php @@ -27,6 +27,8 @@ * - `src/**\/*.php` - Recursive matching * - `tests/**\/*Test.php` - Files ending with Test.php * - `*.md` - Files in root directory + * + * @extends \FilterIterator> */ final class GlobFilterIterator extends FilterIterator { diff --git a/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php b/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php index 970aff0f96c..3d605204d39 100644 --- a/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php +++ b/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php @@ -41,7 +41,7 @@ public function accept(): bool */ public function getChildren(): self { - /** @var \RecursiveIterator $inner */ + /** @var \RecursiveIterator $inner */ $inner = $this->getInnerIterator(); return new self($inner->getChildren()); From a21341f230434aaf0f5e41b142ca53261e9fdf4e Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 6 Mar 2026 14:08:41 +0100 Subject: [PATCH 041/100] Rename MapRequestDto to MapRequestToDto per review feedback Also rename RequestDtoSource to RequestToDtoSource for consistency. --- ...{MapRequestDto.php => MapRequestToDto.php} | 6 ++-- ...stDtoSource.php => RequestToDtoSource.php} | 2 +- src/Controller/ControllerFactory.php | 30 +++++++++---------- .../Controller/ControllerFactoryTest.php | 2 +- .../Controller/DependenciesController.php | 4 +-- 5 files changed, 22 insertions(+), 22 deletions(-) rename src/Controller/Attribute/{MapRequestDto.php => MapRequestToDto.php} (60%) rename src/Controller/Attribute/{RequestDtoSource.php => RequestToDtoSource.php} (87%) diff --git a/src/Controller/Attribute/MapRequestDto.php b/src/Controller/Attribute/MapRequestToDto.php similarity index 60% rename from src/Controller/Attribute/MapRequestDto.php rename to src/Controller/Attribute/MapRequestToDto.php index 54b5f886293..6aacb45ca5e 100644 --- a/src/Controller/Attribute/MapRequestDto.php +++ b/src/Controller/Attribute/MapRequestToDto.php @@ -6,15 +6,15 @@ use Attribute; #[Attribute(Attribute::TARGET_PARAMETER)] -class MapRequestDto +class MapRequestToDto { /** * @param string|null $class DTO class name (optional for typed parameters) - * @param \Cake\Controller\Attribute\RequestDtoSource $source Data source: body, query, request, or auto + * @param \Cake\Controller\Attribute\RequestToDtoSource $source Data source: body, query, request, or auto */ public function __construct( public readonly ?string $class = null, - public readonly RequestDtoSource $source = RequestDtoSource::Auto, + public readonly RequestToDtoSource $source = RequestToDtoSource::Auto, ) { } } diff --git a/src/Controller/Attribute/RequestDtoSource.php b/src/Controller/Attribute/RequestToDtoSource.php similarity index 87% rename from src/Controller/Attribute/RequestDtoSource.php rename to src/Controller/Attribute/RequestToDtoSource.php index 2c55383186f..e6a9434fad7 100644 --- a/src/Controller/Attribute/RequestDtoSource.php +++ b/src/Controller/Attribute/RequestToDtoSource.php @@ -6,7 +6,7 @@ /** * Source for DTO data mapping. */ -enum RequestDtoSource: string +enum RequestToDtoSource: string { case Body = 'body'; case Query = 'query'; diff --git a/src/Controller/ControllerFactory.php b/src/Controller/ControllerFactory.php index b7994e66758..c999e34c9b2 100644 --- a/src/Controller/ControllerFactory.php +++ b/src/Controller/ControllerFactory.php @@ -16,8 +16,8 @@ */ namespace Cake\Controller; -use Cake\Controller\Attribute\MapRequestDto; -use Cake\Controller\Attribute\RequestDtoSource; +use Cake\Controller\Attribute\MapRequestToDto; +use Cake\Controller\Attribute\RequestToDtoSource; use Cake\Controller\Exception\InvalidParameterException; use Cake\Core\App; use Cake\Core\ContainerInterface; @@ -189,7 +189,7 @@ protected function getActionArgs(Closure $action, array $passedParams): array $function = new ReflectionFunction($action); $request = $this->controller->getRequest(); foreach ($function->getParameters() as $parameter) { - $attribute = $this->getMapRequestDtoAttribute($parameter); + $attribute = $this->getMapRequestToDtoAttribute($parameter); if ($attribute !== null) { $resolved[] = $this->resolveDtoFromRequest($parameter, $attribute, $request); continue; @@ -281,12 +281,12 @@ protected function getActionArgs(Closure $action, array $passedParams): array /** * @param \ReflectionParameter $parameter - * @return \Cake\Controller\Attribute\MapRequestDto|null + * @return \Cake\Controller\Attribute\MapRequestToDto|null */ - protected function getMapRequestDtoAttribute(ReflectionParameter $parameter): ?MapRequestDto + protected function getMapRequestToDtoAttribute(ReflectionParameter $parameter): ?MapRequestToDto { - /** @var array<\ReflectionAttribute<\Cake\Controller\Attribute\MapRequestDto>> $attributes */ - $attributes = $parameter->getAttributes(MapRequestDto::class); + /** @var array<\ReflectionAttribute<\Cake\Controller\Attribute\MapRequestToDto>> $attributes */ + $attributes = $parameter->getAttributes(MapRequestToDto::class); foreach ($attributes as $attribute) { return $attribute->newInstance(); } @@ -296,13 +296,13 @@ protected function getMapRequestDtoAttribute(ReflectionParameter $parameter): ?M /** * @param \ReflectionParameter $parameter - * @param \Cake\Controller\Attribute\MapRequestDto $attribute + * @param \Cake\Controller\Attribute\MapRequestToDto $attribute * @param \Cake\Http\ServerRequest $request * @return object */ protected function resolveDtoFromRequest( ReflectionParameter $parameter, - MapRequestDto $attribute, + MapRequestToDto $attribute, ServerRequest $request, ): object { $dtoClass = $attribute->class; @@ -337,19 +337,19 @@ protected function resolveDtoFromRequest( /** * @param \Cake\Http\ServerRequest $request - * @param \Cake\Controller\Attribute\RequestDtoSource $source + * @param \Cake\Controller\Attribute\RequestToDtoSource $source * @return array */ - protected function extractDtoData(ServerRequest $request, RequestDtoSource $source): array + protected function extractDtoData(ServerRequest $request, RequestToDtoSource $source): array { return match ($source) { - RequestDtoSource::Body => (array)$request->getData(), - RequestDtoSource::Query => $request->getQueryParams(), - RequestDtoSource::Request => array_merge( + RequestToDtoSource::Body => (array)$request->getData(), + RequestToDtoSource::Query => $request->getQueryParams(), + RequestToDtoSource::Request => array_merge( $request->getQueryParams(), (array)$request->getData(), ), - RequestDtoSource::Auto => $this->extractAutoDtoData($request), + RequestToDtoSource::Auto => $this->extractAutoDtoData($request), }; } diff --git a/tests/TestCase/Controller/ControllerFactoryTest.php b/tests/TestCase/Controller/ControllerFactoryTest.php index f87bdf273cd..1190a126a6e 100644 --- a/tests/TestCase/Controller/ControllerFactoryTest.php +++ b/tests/TestCase/Controller/ControllerFactoryTest.php @@ -562,7 +562,7 @@ public function testInvokeInjectParametersRequiredNotDefined(): void $this->factory->invoke($controller); } - public function testInvokeMapRequestDtoAttribute(): void + public function testInvokeMapRequestToDtoAttribute(): void { $request = new ServerRequest([ 'url' => 'dependencies/requestDto', diff --git a/tests/test_app/TestApp/Controller/DependenciesController.php b/tests/test_app/TestApp/Controller/DependenciesController.php index 0bf4ecf9f58..35233cfd6c4 100644 --- a/tests/test_app/TestApp/Controller/DependenciesController.php +++ b/tests/test_app/TestApp/Controller/DependenciesController.php @@ -3,7 +3,7 @@ namespace TestApp\Controller; -use Cake\Controller\Attribute\MapRequestDto; +use Cake\Controller\Attribute\MapRequestToDto; use Cake\Controller\Controller; use Cake\Event\EventManagerInterface; use Cake\Http\ServerRequest; @@ -95,7 +95,7 @@ public function requiredDep(stdClass $dep, $any = null, ?string $str = null) } public function requestDto( - #[MapRequestDto] + #[MapRequestToDto] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); From 6a9fb3e5256c47ba01c7c47b4776538efc09776d Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 6 Mar 2026 14:31:14 +0100 Subject: [PATCH 042/100] Add tests for Body, Query, and Request DTO sources --- .../Controller/ControllerFactoryTest.php | 78 +++++++++++++++++++ .../Controller/DependenciesController.php | 22 ++++++ 2 files changed, 100 insertions(+) diff --git a/tests/TestCase/Controller/ControllerFactoryTest.php b/tests/TestCase/Controller/ControllerFactoryTest.php index 1190a126a6e..00447e78c8b 100644 --- a/tests/TestCase/Controller/ControllerFactoryTest.php +++ b/tests/TestCase/Controller/ControllerFactoryTest.php @@ -586,6 +586,84 @@ public function testInvokeMapRequestToDtoAttribute(): void $this->assertSame(['title' => 'Map Request', 'count' => '3'], $data); } + public function testInvokeMapRequestToDtoBodySource(): void + { + $request = new ServerRequest([ + 'url' => 'dependencies/requestDtoBody', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requestDtoBody', + ], + 'post' => [ + 'title' => 'Body Data', + ], + 'query' => [ + 'ignored' => 'query param', + ], + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + + $this->assertSame(['title' => 'Body Data'], $data); + } + + public function testInvokeMapRequestToDtoQuerySource(): void + { + $request = new ServerRequest([ + 'url' => 'dependencies/requestDtoQuery', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requestDtoQuery', + ], + 'post' => [ + 'ignored' => 'post data', + ], + 'query' => [ + 'search' => 'query value', + ], + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + + $this->assertSame(['search' => 'query value'], $data); + } + + public function testInvokeMapRequestToDtoRequestSource(): void + { + $request = new ServerRequest([ + 'url' => 'dependencies/requestDtoRequest', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requestDtoRequest', + ], + 'post' => [ + 'title' => 'Post Title', + ], + 'query' => [ + 'page' => '2', + ], + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + + $this->assertSame(['page' => '2', 'title' => 'Post Title'], $data); + } + public function testInvokeInjectParametersRequiredMissingUntyped(): void { $request = new ServerRequest([ diff --git a/tests/test_app/TestApp/Controller/DependenciesController.php b/tests/test_app/TestApp/Controller/DependenciesController.php index 35233cfd6c4..1788b3f32ca 100644 --- a/tests/test_app/TestApp/Controller/DependenciesController.php +++ b/tests/test_app/TestApp/Controller/DependenciesController.php @@ -4,6 +4,7 @@ namespace TestApp\Controller; use Cake\Controller\Attribute\MapRequestToDto; +use Cake\Controller\Attribute\RequestToDtoSource; use Cake\Controller\Controller; use Cake\Event\EventManagerInterface; use Cake\Http\ServerRequest; @@ -101,6 +102,27 @@ public function requestDto( return $this->response->withStringBody(json_encode($dto->toArray())); } + public function requestDtoBody( + #[MapRequestToDto(source: RequestToDtoSource::Body)] + RequestDataDto $dto, + ) { + return $this->response->withStringBody(json_encode($dto->toArray())); + } + + public function requestDtoQuery( + #[MapRequestToDto(source: RequestToDtoSource::Query)] + RequestDataDto $dto, + ) { + return $this->response->withStringBody(json_encode($dto->toArray())); + } + + public function requestDtoRequest( + #[MapRequestToDto(source: RequestToDtoSource::Request)] + RequestDataDto $dto, + ) { + return $this->response->withStringBody(json_encode($dto->toArray())); + } + /** * @return \Cake\Http\Response */ From 5ca673fb2a01fe1e60cb883b6cd1a4c0437e83da Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 6 Mar 2026 19:06:34 +0100 Subject: [PATCH 043/100] Rename MapRequestToDto to RequestToDto per review feedback --- .../{MapRequestToDto.php => RequestToDto.php} | 2 +- src/Controller/ControllerFactory.php | 16 ++++++++-------- .../Controller/ControllerFactoryTest.php | 8 ++++---- .../Controller/DependenciesController.php | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) rename src/Controller/Attribute/{MapRequestToDto.php => RequestToDto.php} (95%) diff --git a/src/Controller/Attribute/MapRequestToDto.php b/src/Controller/Attribute/RequestToDto.php similarity index 95% rename from src/Controller/Attribute/MapRequestToDto.php rename to src/Controller/Attribute/RequestToDto.php index 6aacb45ca5e..d3ca8d56c17 100644 --- a/src/Controller/Attribute/MapRequestToDto.php +++ b/src/Controller/Attribute/RequestToDto.php @@ -6,7 +6,7 @@ use Attribute; #[Attribute(Attribute::TARGET_PARAMETER)] -class MapRequestToDto +class RequestToDto { /** * @param string|null $class DTO class name (optional for typed parameters) diff --git a/src/Controller/ControllerFactory.php b/src/Controller/ControllerFactory.php index c999e34c9b2..3825dc98b3f 100644 --- a/src/Controller/ControllerFactory.php +++ b/src/Controller/ControllerFactory.php @@ -16,7 +16,7 @@ */ namespace Cake\Controller; -use Cake\Controller\Attribute\MapRequestToDto; +use Cake\Controller\Attribute\RequestToDto; use Cake\Controller\Attribute\RequestToDtoSource; use Cake\Controller\Exception\InvalidParameterException; use Cake\Core\App; @@ -189,7 +189,7 @@ protected function getActionArgs(Closure $action, array $passedParams): array $function = new ReflectionFunction($action); $request = $this->controller->getRequest(); foreach ($function->getParameters() as $parameter) { - $attribute = $this->getMapRequestToDtoAttribute($parameter); + $attribute = $this->getRequestToDtoAttribute($parameter); if ($attribute !== null) { $resolved[] = $this->resolveDtoFromRequest($parameter, $attribute, $request); continue; @@ -281,12 +281,12 @@ protected function getActionArgs(Closure $action, array $passedParams): array /** * @param \ReflectionParameter $parameter - * @return \Cake\Controller\Attribute\MapRequestToDto|null + * @return \Cake\Controller\Attribute\RequestToDto|null */ - protected function getMapRequestToDtoAttribute(ReflectionParameter $parameter): ?MapRequestToDto + protected function getRequestToDtoAttribute(ReflectionParameter $parameter): ?RequestToDto { - /** @var array<\ReflectionAttribute<\Cake\Controller\Attribute\MapRequestToDto>> $attributes */ - $attributes = $parameter->getAttributes(MapRequestToDto::class); + /** @var array<\ReflectionAttribute<\Cake\Controller\Attribute\RequestToDto>> $attributes */ + $attributes = $parameter->getAttributes(RequestToDto::class); foreach ($attributes as $attribute) { return $attribute->newInstance(); } @@ -296,13 +296,13 @@ protected function getMapRequestToDtoAttribute(ReflectionParameter $parameter): /** * @param \ReflectionParameter $parameter - * @param \Cake\Controller\Attribute\MapRequestToDto $attribute + * @param \Cake\Controller\Attribute\RequestToDto $attribute * @param \Cake\Http\ServerRequest $request * @return object */ protected function resolveDtoFromRequest( ReflectionParameter $parameter, - MapRequestToDto $attribute, + RequestToDto $attribute, ServerRequest $request, ): object { $dtoClass = $attribute->class; diff --git a/tests/TestCase/Controller/ControllerFactoryTest.php b/tests/TestCase/Controller/ControllerFactoryTest.php index 00447e78c8b..de2579702c7 100644 --- a/tests/TestCase/Controller/ControllerFactoryTest.php +++ b/tests/TestCase/Controller/ControllerFactoryTest.php @@ -562,7 +562,7 @@ public function testInvokeInjectParametersRequiredNotDefined(): void $this->factory->invoke($controller); } - public function testInvokeMapRequestToDtoAttribute(): void + public function testInvokeRequestToDtoAttribute(): void { $request = new ServerRequest([ 'url' => 'dependencies/requestDto', @@ -586,7 +586,7 @@ public function testInvokeMapRequestToDtoAttribute(): void $this->assertSame(['title' => 'Map Request', 'count' => '3'], $data); } - public function testInvokeMapRequestToDtoBodySource(): void + public function testInvokeRequestToDtoBodySource(): void { $request = new ServerRequest([ 'url' => 'dependencies/requestDtoBody', @@ -612,7 +612,7 @@ public function testInvokeMapRequestToDtoBodySource(): void $this->assertSame(['title' => 'Body Data'], $data); } - public function testInvokeMapRequestToDtoQuerySource(): void + public function testInvokeRequestToDtoQuerySource(): void { $request = new ServerRequest([ 'url' => 'dependencies/requestDtoQuery', @@ -638,7 +638,7 @@ public function testInvokeMapRequestToDtoQuerySource(): void $this->assertSame(['search' => 'query value'], $data); } - public function testInvokeMapRequestToDtoRequestSource(): void + public function testInvokeRequestToDtoRequestSource(): void { $request = new ServerRequest([ 'url' => 'dependencies/requestDtoRequest', diff --git a/tests/test_app/TestApp/Controller/DependenciesController.php b/tests/test_app/TestApp/Controller/DependenciesController.php index 1788b3f32ca..2f0ba483ccd 100644 --- a/tests/test_app/TestApp/Controller/DependenciesController.php +++ b/tests/test_app/TestApp/Controller/DependenciesController.php @@ -3,7 +3,7 @@ namespace TestApp\Controller; -use Cake\Controller\Attribute\MapRequestToDto; +use Cake\Controller\Attribute\RequestToDto; use Cake\Controller\Attribute\RequestToDtoSource; use Cake\Controller\Controller; use Cake\Event\EventManagerInterface; @@ -96,28 +96,28 @@ public function requiredDep(stdClass $dep, $any = null, ?string $str = null) } public function requestDto( - #[MapRequestToDto] + #[RequestToDto] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); } public function requestDtoBody( - #[MapRequestToDto(source: RequestToDtoSource::Body)] + #[RequestToDto(source: RequestToDtoSource::Body)] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); } public function requestDtoQuery( - #[MapRequestToDto(source: RequestToDtoSource::Query)] + #[RequestToDto(source: RequestToDtoSource::Query)] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); } public function requestDtoRequest( - #[MapRequestToDto(source: RequestToDtoSource::Request)] + #[RequestToDto(source: RequestToDtoSource::Request)] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); From 19d1e8081900d8457db921b1d89dd86006cd66a7 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 7 Mar 2026 09:09:49 +0530 Subject: [PATCH 044/100] Make FormHelper::enumOptions() public. (#19312) This allow generating select options when the form is created without context. --- src/View/Helper/FormHelper.php | 2 +- tests/TestCase/View/Helper/FormHelperTest.php | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index c9bc5c57fa6..e27fd22f427 100644 --- a/src/View/Helper/FormHelper.php +++ b/src/View/Helper/FormHelper.php @@ -1393,7 +1393,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)); diff --git a/tests/TestCase/View/Helper/FormHelperTest.php b/tests/TestCase/View/Helper/FormHelperTest.php index 86db938309a..6280751cd76 100644 --- a/tests/TestCase/View/Helper/FormHelperTest.php +++ b/tests/TestCase/View/Helper/FormHelperTest.php @@ -3940,6 +3940,37 @@ public function testEnumOptionsDeprecationMessage(): void }); } + /** + * Test enumOptions() returns correct value => label mapping for backed enums. + */ + public function testEnumOptions(): void + { + // Basic backed enum without EnumLabelInterface - labels from humanized case names + $result = $this->Form->enumOptions(ArticleStatus::class); + $expected = [ + 'Y' => 'Published', + 'N' => 'Unpublished', + ]; + $this->assertSame($expected, $result); + + // Enum implementing EnumLabelInterface - labels from label() method + $result = $this->Form->enumOptions(ArticleStatusLabelInterface::class); + $expected = [ + 'Y' => 'Is Published', + 'N' => 'Is Unpublished', + ]; + $this->assertSame($expected, $result); + + // Integer-backed enum implementing EnumLabelInterface + $result = $this->Form->enumOptions(Priority::class); + $expected = [ + 1 => 'Is Low', + 2 => 'Is Medium', + 3 => 'Is High', + ]; + $this->assertSame($expected, $result); + } + /** * testControlWithNonStandardPrimaryKeyMakesHidden method * From 6a806b962c32313f276d31de16d8d41a20f41a90 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 7 Mar 2026 19:05:47 +0100 Subject: [PATCH 045/100] Add Enum suffix to RequestToDtoSourceEnum Rename enum as per CakePHP convention. --- src/Controller/Attribute/RequestToDto.php | 4 ++-- ...tToDtoSource.php => RequestToDtoSourceEnum.php} | 2 +- src/Controller/ControllerFactory.php | 14 +++++++------- .../TestApp/Controller/DependenciesController.php | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) rename src/Controller/Attribute/{RequestToDtoSource.php => RequestToDtoSourceEnum.php} (85%) diff --git a/src/Controller/Attribute/RequestToDto.php b/src/Controller/Attribute/RequestToDto.php index d3ca8d56c17..ecdee90b47e 100644 --- a/src/Controller/Attribute/RequestToDto.php +++ b/src/Controller/Attribute/RequestToDto.php @@ -10,11 +10,11 @@ class RequestToDto { /** * @param string|null $class DTO class name (optional for typed parameters) - * @param \Cake\Controller\Attribute\RequestToDtoSource $source Data source: body, query, request, or auto + * @param \Cake\Controller\Attribute\RequestToDtoSourceEnum $source Data source: body, query, request, or auto */ public function __construct( public readonly ?string $class = null, - public readonly RequestToDtoSource $source = RequestToDtoSource::Auto, + public readonly RequestToDtoSourceEnum $source = RequestToDtoSourceEnum::Auto, ) { } } diff --git a/src/Controller/Attribute/RequestToDtoSource.php b/src/Controller/Attribute/RequestToDtoSourceEnum.php similarity index 85% rename from src/Controller/Attribute/RequestToDtoSource.php rename to src/Controller/Attribute/RequestToDtoSourceEnum.php index e6a9434fad7..56434403669 100644 --- a/src/Controller/Attribute/RequestToDtoSource.php +++ b/src/Controller/Attribute/RequestToDtoSourceEnum.php @@ -6,7 +6,7 @@ /** * Source for DTO data mapping. */ -enum RequestToDtoSource: string +enum RequestToDtoSourceEnum: string { case Body = 'body'; case Query = 'query'; diff --git a/src/Controller/ControllerFactory.php b/src/Controller/ControllerFactory.php index 3825dc98b3f..305a045018b 100644 --- a/src/Controller/ControllerFactory.php +++ b/src/Controller/ControllerFactory.php @@ -17,7 +17,7 @@ namespace Cake\Controller; use Cake\Controller\Attribute\RequestToDto; -use Cake\Controller\Attribute\RequestToDtoSource; +use Cake\Controller\Attribute\RequestToDtoSourceEnum; use Cake\Controller\Exception\InvalidParameterException; use Cake\Core\App; use Cake\Core\ContainerInterface; @@ -337,19 +337,19 @@ protected function resolveDtoFromRequest( /** * @param \Cake\Http\ServerRequest $request - * @param \Cake\Controller\Attribute\RequestToDtoSource $source + * @param \Cake\Controller\Attribute\RequestToDtoSourceEnum $source * @return array */ - protected function extractDtoData(ServerRequest $request, RequestToDtoSource $source): array + protected function extractDtoData(ServerRequest $request, RequestToDtoSourceEnum $source): array { return match ($source) { - RequestToDtoSource::Body => (array)$request->getData(), - RequestToDtoSource::Query => $request->getQueryParams(), - RequestToDtoSource::Request => array_merge( + RequestToDtoSourceEnum::Body => (array)$request->getData(), + RequestToDtoSourceEnum::Query => $request->getQueryParams(), + RequestToDtoSourceEnum::Request => array_merge( $request->getQueryParams(), (array)$request->getData(), ), - RequestToDtoSource::Auto => $this->extractAutoDtoData($request), + RequestToDtoSourceEnum::Auto => $this->extractAutoDtoData($request), }; } diff --git a/tests/test_app/TestApp/Controller/DependenciesController.php b/tests/test_app/TestApp/Controller/DependenciesController.php index 2f0ba483ccd..697d5a0be26 100644 --- a/tests/test_app/TestApp/Controller/DependenciesController.php +++ b/tests/test_app/TestApp/Controller/DependenciesController.php @@ -4,7 +4,7 @@ namespace TestApp\Controller; use Cake\Controller\Attribute\RequestToDto; -use Cake\Controller\Attribute\RequestToDtoSource; +use Cake\Controller\Attribute\RequestToDtoSourceEnum; use Cake\Controller\Controller; use Cake\Event\EventManagerInterface; use Cake\Http\ServerRequest; @@ -103,21 +103,21 @@ public function requestDto( } public function requestDtoBody( - #[RequestToDto(source: RequestToDtoSource::Body)] + #[RequestToDto(source: RequestToDtoSourceEnum::Body)] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); } public function requestDtoQuery( - #[RequestToDto(source: RequestToDtoSource::Query)] + #[RequestToDto(source: RequestToDtoSourceEnum::Query)] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); } public function requestDtoRequest( - #[RequestToDto(source: RequestToDtoSource::Request)] + #[RequestToDto(source: RequestToDtoSourceEnum::Request)] RequestDataDto $dto, ) { return $this->response->withStringBody(json_encode($dto->toArray())); From fbf19101a9f2ffcdade5fb3554a64887129786cb Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Tue, 10 Mar 2026 16:00:48 +0100 Subject: [PATCH 046/100] Add {{inputId}} template variable to inputContainer and error templates (#19317) This allows using the HTML field name in custom templates for these elements, enabling use cases like creating field-specific error container IDs for AJAX. Fixes #18955 * Add `inputId` template variable to inputContainer and error templates The `inputId` variable provides the input element's HTML id attribute, useful for generating related element IDs (e.g., for ARIA attributes or custom JavaScript). * Remove {{name}} template variable, keep only {{inputId}} --- src/View/Helper/FormHelper.php | 35 ++++++++--- tests/TestCase/View/Helper/FormHelperTest.php | 63 +++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index e27fd22f427..92708c8af32 100644 --- a/src/View/Helper/FormHelper.php +++ b/src/View/Helper/FormHelper.php @@ -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'], @@ -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/tests/TestCase/View/Helper/FormHelperTest.php b/tests/TestCase/View/Helper/FormHelperTest.php index 6280751cd76..5b68b4ad9eb 100644 --- a/tests/TestCase/View/Helper/FormHelperTest.php +++ b/tests/TestCase/View/Helper/FormHelperTest.php @@ -567,6 +567,69 @@ public function testControlTemplateVars(): void $this->assertHtml($expected, $result); } + /** + * Test that `{{inputId}}` template variable is available in inputContainer template. + */ + public function testControlInputIdTemplateVar(): void + { + $result = $this->Form->control('title', [ + 'templates' => [ + 'inputContainer' => '
{{content}}
', + ], + ]); + $this->assertStringContainsString('data-input-id="title"', $result); + + // Test nested field name + $result = $this->Form->control('User.email', [ + 'templates' => [ + 'inputContainer' => '
{{content}}
', + ], + ]); + $this->assertStringContainsString('data-input-id="user-email"', $result); + + // Test deeply nested field name + $result = $this->Form->control('User.address.city', [ + 'templates' => [ + 'inputContainer' => '
{{content}}
', + ], + ]); + $this->assertStringContainsString('data-input-id="user-address-city"', $result); + } + + /** + * Test that `{{inputId}}` template variable is available in error template. + */ + public function testErrorInputIdTemplateVar(): void + { + $this->article['errors'] = [ + 'title' => ['error message'], + ]; + $this->Form->create($this->article); + + $result = $this->Form->control('title', [ + 'templates' => [ + 'error' => '
{{content}}
', + ], + ]); + $this->assertStringContainsString('aria-describedby="title"', $result); + $this->assertStringContainsString('error message', $result); + + // Test nested field name + $this->article['errors'] = [ + 'author' => [ + 'name' => ['Author name is required'], + ], + ]; + $this->Form->create($this->article); + + $result = $this->Form->control('author.name', [ + 'templates' => [ + 'error' => '
{{content}}
', + ], + ]); + $this->assertStringContainsString('aria-describedby="author-name"', $result); + } + /** * Test ensuring template variables work in template files loaded * during control(). From 7f09e17f829efd2543ada53a9f44900d667cdea0 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 13 Mar 2026 16:52:02 -0400 Subject: [PATCH 047/100] Improve Security::encrypt key strength (#19325) Thank you to 'Pigeondrops' who contacted us through the security mailing list about a weakness in the `Security::encrypt()` key handling. > This is likely a low severity since AES-128-CBC is still not fully > deprecated (the reference will make sense later in this report I know in > code AES-256-CBC is being used). But Post-quantum guidance (due to > grover's algorithm > https://postquantum.com/post-quantum/grovers-algorithm/) and generally > in financial sector, CNSA 2.0 is moving away from AES-128-cbc. The problem with the current implementation is that the keys are converted to a hexadecimal string and then truncated. Because each byte requires 2 characters in hexadecimal representation this halves the entropy in the key. With the `Security.encryptWithRawKey` configure value is enabled, encrypted values will use a full 32 bytes, and furthermore will use key derivation with separate encryption and authentication keys. * Improve docs --- src/Utility/Security.php | 43 +++++++++++++++++++++---- tests/TestCase/Utility/SecurityTest.php | 28 ++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/Utility/Security.php b/src/Utility/Security.php index b00d3ecc86f..79a57127749 100644 --- a/src/Utility/Security.php +++ b/src/Utility/Security.php @@ -16,6 +16,7 @@ */ namespace Cake\Utility; +use Cake\Core\Configure; use Cake\Core\Exception\CakeException; use Cake\Utility\Crypto\OpenSsl; use InvalidArgumentException; @@ -201,12 +202,13 @@ public static function encrypt(string $plain, string $key, ?string $hmacSalt = n self::_checkKey($key, 'encrypt()'); $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); $crypto = static::engine(); - $ciphertext = $crypto->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/tests/TestCase/Utility/SecurityTest.php b/tests/TestCase/Utility/SecurityTest.php index d48c6465d56..47b008de75a 100644 --- a/tests/TestCase/Utility/SecurityTest.php +++ b/tests/TestCase/Utility/SecurityTest.php @@ -16,6 +16,7 @@ */ namespace Cake\Test\TestCase\Utility; +use Cake\Core\Configure; use Cake\TestSuite\TestCase; use Cake\Utility\Crypto\OpenSsl; use Cake\Utility\Security; @@ -106,6 +107,33 @@ public function testEncryptDecrypt(): void $this->assertSame($txt, Security::decrypt($result, $key)); } + /** + * Test encrypt/decrypt with raw key feature. + * + * Raw keys give results that are more quantum resistant. + */ + public function testEncryptDecryptRawKey(): void + { + Configure::write('Security.encryptWithRawKey', true); + + $txt = 'The quick brown fox'; + $key = 'This key is longer than 32 bytes long.'; + $result = Security::encrypt($txt, $key); + $this->assertNotEquals($txt, $result, 'Should be encrypted.'); + $this->assertNotEquals($result, Security::encrypt($txt, $key), 'Each result is unique.'); + $this->assertSame($txt, Security::decrypt($result, $key)); + + Configure::write('Security.encryptWithRawKey', false); + + $oldKeyResult = Security::encrypt($txt, $key); + $this->assertNotEquals($txt, $oldKeyResult, 'Should be encrypted.'); + $this->assertSame($txt, Security::decrypt($oldKeyResult, $key)); + $this->assertNull( + Security::decrypt($result, $key), + 'value encrypted with new key cannot be decrypted with old', + ); + } + /** * Test that changing the key causes decryption to fail. */ From 7769d12bbe0e3d5c288795f4a10dc5b6dee911da Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 14 Mar 2026 04:49:28 +0100 Subject: [PATCH 048/100] Move extractData to RequestToDto attribute Moves data extraction logic from ControllerFactory to the attribute class itself, making the attribute responsible for its own behavior. --- src/Controller/Attribute/RequestToDto.php | 40 ++++++++++++++++++++++ src/Controller/ControllerFactory.php | 41 +---------------------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/Controller/Attribute/RequestToDto.php b/src/Controller/Attribute/RequestToDto.php index ecdee90b47e..506cd8976ba 100644 --- a/src/Controller/Attribute/RequestToDto.php +++ b/src/Controller/Attribute/RequestToDto.php @@ -4,6 +4,7 @@ namespace Cake\Controller\Attribute; use Attribute; +use Cake\Http\ServerRequest; #[Attribute(Attribute::TARGET_PARAMETER)] class RequestToDto @@ -17,4 +18,43 @@ public function __construct( public readonly RequestToDtoSourceEnum $source = RequestToDtoSourceEnum::Auto, ) { } + + /** + * Extract data from request based on source. + * + * @param \Cake\Http\ServerRequest $request The server request + * @return array + */ + public function extractData(ServerRequest $request): array + { + return match ($this->source) { + RequestToDtoSourceEnum::Body => (array)$request->getData(), + RequestToDtoSourceEnum::Query => $request->getQueryParams(), + RequestToDtoSourceEnum::Request => array_merge( + $request->getQueryParams(), + (array)$request->getData(), + ), + RequestToDtoSourceEnum::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/ControllerFactory.php b/src/Controller/ControllerFactory.php index 305a045018b..2d2b0601020 100644 --- a/src/Controller/ControllerFactory.php +++ b/src/Controller/ControllerFactory.php @@ -17,7 +17,6 @@ namespace Cake\Controller; use Cake\Controller\Attribute\RequestToDto; -use Cake\Controller\Attribute\RequestToDtoSourceEnum; use Cake\Controller\Exception\InvalidParameterException; use Cake\Core\App; use Cake\Core\ContainerInterface; @@ -329,46 +328,8 @@ protected function resolveDtoFromRequest( ]); } - $data = $this->extractDtoData($request, $attribute->source); - /** @var class-string $dtoClass */ - return $dtoClass::createFromArray($data); - } - - /** - * @param \Cake\Http\ServerRequest $request - * @param \Cake\Controller\Attribute\RequestToDtoSourceEnum $source - * @return array - */ - protected function extractDtoData(ServerRequest $request, RequestToDtoSourceEnum $source): array - { - return match ($source) { - RequestToDtoSourceEnum::Body => (array)$request->getData(), - RequestToDtoSourceEnum::Query => $request->getQueryParams(), - RequestToDtoSourceEnum::Request => array_merge( - $request->getQueryParams(), - (array)$request->getData(), - ), - RequestToDtoSourceEnum::Auto => $this->extractAutoDtoData($request), - }; - } - - /** - * @param \Cake\Http\ServerRequest $request - * @return array - */ - protected function extractAutoDtoData(ServerRequest $request): array - { - if ($request->is(['get', 'head'])) { - return $request->getQueryParams(); - } - - $data = (array)$request->getData(); - if ($data !== []) { - return $data; - } - - return $request->getQueryParams(); + return $dtoClass::createFromArray($attribute->extractData($request)); } /** From fad7ccf2a29265b16b41fe9516b41b672524c1f5 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 14 Mar 2026 13:15:25 +0530 Subject: [PATCH 049/100] Mark unused Mailer::$name as deprecated --- src/Mailer/Mailer.php | 1 + 1 file changed, 1 insertion(+) 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; From 94328e08c4159045ecdf800c9df5e1506febc08d Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 14 Mar 2026 13:16:41 +0530 Subject: [PATCH 050/100] Change visibility of FormHelper::$requestType. It was incorrectly marked as public --- src/View/Helper/FormHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index 92708c8af32..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. From a3fd8d074809a18efd766f99b7dec85f04b73f01 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 14 Mar 2026 13:24:04 +0530 Subject: [PATCH 051/100] Make CollectionOf::$class readonly --- src/ORM/Attribute/CollectionOf.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ORM/Attribute/CollectionOf.php b/src/ORM/Attribute/CollectionOf.php index 7c60dbff5df..d380dd6554e 100644 --- a/src/ORM/Attribute/CollectionOf.php +++ b/src/ORM/Attribute/CollectionOf.php @@ -44,7 +44,7 @@ class CollectionOf * @param class-string $class The DTO class for collection elements */ public function __construct( - public string $class, + public readonly string $class, ) { } } From 60ff3cc777ee1b7bb61eaa90016f72ad8250472a Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 15 Mar 2026 19:45:56 +0530 Subject: [PATCH 052/100] Remove underscore prefix from new method. (#19337) This avoids changing is again on 6.x --- src/Utility/Security.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Utility/Security.php b/src/Utility/Security.php index 79a57127749..459c9b5e5f4 100644 --- a/src/Utility/Security.php +++ b/src/Utility/Security.php @@ -204,7 +204,7 @@ public static function encrypt(string $plain, string $key, ?string $hmacSalt = n $hmacSalt ??= static::getSalt(); // Generate the encryption and hmac key. - [$encryptionKey, $hmacKey] = static::_makeEncryptionKeys($key, $hmacSalt); + [$encryptionKey, $hmacKey] = static::makeEncryptionKeys($key, $hmacSalt); $crypto = static::engine(); $ciphertext = $crypto->encrypt($plain, $encryptionKey); @@ -244,7 +244,7 @@ protected static function _checkKey(string $key, string $method): void * @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 + protected static function makeEncryptionKeys(string $key, string $hmacSalt): array { if (Configure::read('Security.encryptWithRawKey') === true) { $encryption = hash_hkdf('sha256', $key, 32, 'encryption', $hmacSalt); @@ -278,7 +278,7 @@ public static function decrypt(string $cipher, string $key, ?string $hmacSalt = $hmacSalt ??= static::getSalt(); // Generate the encryption and hmac key. - [$encryptionKey, $hmacKey] = static::_makeEncryptionKeys($key, $hmacSalt); + [$encryptionKey, $hmacKey] = static::makeEncryptionKeys($key, $hmacSalt); // Split out hmac for comparison $macSize = 64; From cd55b3f41f0c6ecf3439c0f03f7bc9f0139148eb Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sun, 15 Mar 2026 22:22:32 +0100 Subject: [PATCH 053/100] Console UX improvements for help output and third-party integration (#19303) * feat: allow custom headers in console help * fix stan + address copilot reviews --- src/Console/Command/HelpCommand.php | 57 ++++++++++++++++--- src/Console/CommandRunner.php | 6 +- .../ConsoleHelpHeaderProviderInterface.php | 29 ++++++++++ .../Command/CompletionCommandTest.php | 1 - .../Console/Command/HelpCommandTest.php | 46 +++++++++++---- tests/TestCase/Console/CommandRunnerTest.php | 30 +++++++++- 6 files changed, 146 insertions(+), 23 deletions(-) create mode 100644 src/Core/ConsoleHelpHeaderProviderInterface.php 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 a43c974d1db..0c198992111 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/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 @@ +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('