From f13dc33cfea15314d2997128de736687868c8925 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sun, 23 May 2021 19:00:08 +0000 Subject: [PATCH 01/65] Remove pasted imports --- tests/system/Helpers/FilesystemHelperTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/system/Helpers/FilesystemHelperTest.php b/tests/system/Helpers/FilesystemHelperTest.php index ea9b378646ef..d7367fe07276 100644 --- a/tests/system/Helpers/FilesystemHelperTest.php +++ b/tests/system/Helpers/FilesystemHelperTest.php @@ -6,9 +6,6 @@ use InvalidArgumentException; use org\bovigo\vfs\vfsStream; -use org\bovigo\vfs\vfsStreamDirectory; -use org\bovigo\vfs\visitor\vfsStreamStructureVisitor; - class FilesystemHelperTest extends CIUnitTestCase { protected function setUp(): void From cd662944750bfdb639fa8ce70df91513e555dca8 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sun, 23 May 2021 19:09:49 +0000 Subject: [PATCH 02/65] Add Publisher --- system/Language/en/Publisher.php | 17 + .../Exceptions/PublisherException.php | 32 + system/Publisher/Publisher.php | 713 ++++++++++++++++++ tests/_support/Publishers/TestPublisher.php | 16 + 4 files changed, 778 insertions(+) create mode 100644 system/Language/en/Publisher.php create mode 100644 system/Publisher/Exceptions/PublisherException.php create mode 100644 system/Publisher/Publisher.php create mode 100644 tests/_support/Publishers/TestPublisher.php diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php new file mode 100644 index 000000000000..ada599074efe --- /dev/null +++ b/system/Language/en/Publisher.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// Publisher language settings +return [ + 'expectedFile' => 'Publisher::{0} expects a valid file.', + 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', + 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', +]; diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php new file mode 100644 index 000000000000..23a4b54d3e57 --- /dev/null +++ b/system/Publisher/Exceptions/PublisherException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Publisher\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +class PublisherException extends FrameworkException +{ + public static function forExpectedDirectory(string $caller) + { + return new static(lang('Publisher.expectedDirectory', [$caller])); + } + + public static function forExpectedFile(string $caller) + { + return new static(lang('Publisher.expectedFile', [$caller])); + } + + public static function forCollision(string $from, string $to) + { + return new static(lang('Publisher.collision', [filetype($to), $from, $to])); + } +} diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php new file mode 100644 index 000000000000..8f599d3292b6 --- /dev/null +++ b/system/Publisher/Publisher.php @@ -0,0 +1,713 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Publisher; + +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Files\File; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Publisher\Exceptions\PublisherException; +use Throwable; + +/** + * Publisher Class + * + * Publishers read in file paths from a variety + * of sources and copy the files out to different + * destinations. + * This class acts both as a base for individual + * publication directives as well as the mode of + * discovery for said instances. + * In this class a "file" is a full path to a + * verified file while a "path" is relative to + * to its source or destination and may indicate + * either a file or directory fo unconfirmed + * existence. + * Class failures throw a PublisherException, + * but some underlying methods may percolate + * different exceptions, like FileException, + * FileNotFoundException, InvalidArgumentException. + * Write operations will catch all errors in the + * file-specific $errors property to minimize + * impact of partial batch operations. + */ +class Publisher +{ + /** + * Array of discovered Publishers. + * + * @var array + */ + private static $discovered = []; + + /** + * Directory to use for methods + * that need temporary storage. + * Created on-the-fly as needed. + * + * @var string|null + */ + private $scratch; + + /** + * The current list of files. + * + * @var string[] + */ + private $files = []; + + /** + * Exceptions for specific files + * from the last write operation. + * + * @var array + */ + private $errors = []; + + /** + * Base path to use for the source. + * + * @var string + */ + protected $source = ROOTPATH; + + /** + * Base path to use for the destination. + * + * @var string + */ + protected $destination = FCPATH; + + /** + * Discovers and returns all Publishers + * in the specified namespace directory. + * + * @return self[] + */ + public static function discover($directory = 'Publishers'): array + { + if (isset(self::$discovered[$directory])) + { + return self::$discovered[$directory]; + } + self::$discovered[$directory] = []; + + /** @var FileLocator $locator */ + $locator = service('locator'); + + if ([] === $files = $locator->listFiles($directory)) + { + return []; + } + + // Loop over each file checking to see if it is a Primer + foreach ($files as $file) + { + $className = $locator->findQualifiedNameFromPath($file); + + if (is_string($className) && class_exists($className) && is_a($className, self::class, true)) + { + self::$discovered[$directory][] = new $className(); + } + } + sort(self::$discovered[$directory]); + + return self::$discovered[$directory]; + } + + //-------------------------------------------------------------------- + + /** + * Resolves a full path and verifies + * it is an actual directory. + * + * @param string $directory + * + * @return string + */ + private static function resolveDirectory(string $directory): string + { + if (! is_dir($directory = set_realpath($directory))) + { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + throw PublisherException::forExpectedDirectory($caller['function']); + } + + return $directory; + } + + /** + * Resolves a full path and verifies + * it is an actual file. + * + * @param string $file + * + * @return string + */ + private static function resolveFile(string $file): string + { + if (! is_file($file = set_realpath($file))) + { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + throw PublisherException::forExpectedFile($caller['function']); + } + + return $file; + } + + //-------------------------------------------------------------------- + + /** + * Filters an array of files, removing files not + * part of the given directory (recursive). + * + * @param string[] $files + * @param string $directory + * + * @return string[] + */ + private static function filterFiles(array $files, string $directory): array + { + $directory = self::resolveDirectory($directory); + + return array_filter($files, function ($value) use ($directory) { + return strpos($value, $directory) === 0; + }); + } + + /** + * Returns any files whose basename matches + * the given pattern. + * + * @param string[] $files + * @param string $pattern Regex or pseudo-regex string + * + * @return string[] + */ + private static function matchFiles(array $files, string $pattern) + { + // Convert pseudo-regex into their true form + if (@preg_match($pattern, null) === false) // @phpstan-ignore-line + { + $pattern = str_replace( + ['#', '.', '*', '?'], + ['\#', '\.', '.*', '.'], + $pattern + ); + $pattern = "#{$pattern}#"; + } + + return array_filter($files, function ($value) use ($pattern) { + return (bool) preg_match($pattern, basename($value)); + }); + } + + //-------------------------------------------------------------------- + + /** + * Removes a directory and all its files + * and subdirectories. + * + * @param string $directory + * + * @return void + */ + private static function wipeDirectory(string $directory) + { + if (is_dir($directory)) + { + // Try a few times in case of lingering locks + $attempts = 10; + while ((bool) $attempts && ! delete_files($directory, true, false, true)) + { + $attempts--; + usleep(100000); // .1s + } + + @rmdir($directory); + } + } + + /** + * Copies a file with directory creation + * and identical file awareness. + * Intentionally allows errors. + * + * @param string $from + * @param string $to + * @param bool $replace + * + * @return void + * + * @throws PublisherException For unresolvable collisions + */ + private static function safeCopyFile(string $from, string $to, bool $replace): void + { + // Check for an existing file + if (file_exists($to)) + { + // If not replacing or if files are identical then consider successful + if (! $replace || same_file($from, $to)) + { + return; + } + + // If it is a directory then do not try to remove it + if (is_dir($to)) + { + throw PublisherException::forCollision($from, $to); + } + + // Try to remove anything else + unlink($to); + } + + // Allow copy() to throw errors + copy($from, $to); + } + + //-------------------------------------------------------------------- + + /** + * Loads the helper and verifies the + * source and destination directories. + * + * @param string|null $source + * @param string|null $destination + */ + public function __construct(string $source = null, string $destination = null) + { + helper(['filesystem']); + + $this->source = self::resolveDirectory($source ?? $this->source); + $this->destination = self::resolveDirectory($destination ?? $this->destination); + } + + /** + * Cleans up any temporary files + * in the scratch space. + */ + public function __destruct() + { + if (isset($this->scratch)) + { + self::wipeDirectory($this->scratch); + + $this->scratch = null; + } + } + + /** + * Reads in file sources and copies out + * the files to their destinations. + * This method should be reimplemented by + * child classes intended for discovery. + * + * @return void + */ + public function publish() + { + } + + //-------------------------------------------------------------------- + + /** + * Returns the temporary workspace, + * creating it if necessary. + * + * @return string + */ + protected function getScratch(): string + { + if (is_null($this->scratch)) + { + $this->scratch = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)) . DIRECTORY_SEPARATOR; + mkdir($this->scratch, 0700); + } + + return $this->scratch; + } + + /** + * Returns any errors from the last + * write operation. + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Optimizes and returns the + * current file list. + * + * @return string[] + */ + public function getFiles(): array + { + $this->files = array_unique($this->files, SORT_STRING); + sort($this->files, SORT_STRING); + + return $this->files; + } + + /** + * Sets the file list directly. + * Files are still subject to verification. + * This works as a "reset" method with []. + * + * @param string[] $files A new file list to use + * + * @return $this + */ + public function setFiles(array $files) + { + $this->files = []; + + return $this->addFiles($files); + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds files to the list. + * + * @param string[] $files + * + * @return $this + */ + public function addFiles(array $files) + { + foreach ($files as $file) + { + $this->addFile($file); + } + + return $this; + } + + /** + * Verifies and adds a single file + * to the file list. + * + * @param string $file + * + * @return $this + */ + public function addFile(string $file) + { + $this->files[] = self::resolveFile($file); + + return $this; + } + + /** + * Removes files from the list. + * + * @param string[] $files + * + * @return $this + */ + public function removeFiles(array $files) + { + $this->files = array_diff($this->files, $files); + + return $this; + } + + /** + * Removes a single file from the list. + * + * @param string $file + * + * @return $this + */ + public function removeFile(string $file) + { + return $this->removeFiles([$file]); + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds files from each + * directory to the list. + * + * @param string[] $directories + * @param bool $recursive + * + * @return $this + */ + public function addDirectories(array $directories, bool $recursive = false) + { + foreach ($directories as $directory) + { + $this->addDirectory($directory, $recursive); + } + + return $this; + } + + /** + * Verifies and adds all files + * from a directory. + * + * @param string $directory + * @param bool $recursive + * + * @return $this + */ + public function addDirectory(string $directory, bool $recursive = false) + { + $directory = self::resolveDirectory($directory); + + // Map the directory to depth 2 to so directories become arrays + foreach (directory_map($directory, 2, true) as $key => $path) + { + if (is_string($path)) + { + $this->addFile($directory . $path); + } + elseif ($recursive && is_array($path)) + { + $this->addDirectory($directory . $key, true); + } + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds paths to the list. + * + * @param string[] $paths + * @param bool $recursive + * + * @return $this + */ + public function addPaths(array $paths, bool $recursive = true) + { + foreach ($paths as $path) + { + $this->addPath($path, $recursive); + } + + return $this; + } + + /** + * Adds a single path to the file list. + * + * @param string $path + * @param bool $recursive + * + * @return $this + */ + public function addPath(string $path, bool $recursive = true) + { + $full = $this->source . $path; + + // Test for a directory + try + { + $directory = self::resolveDirectory($full); + } + catch (PublisherException $e) + { + return $this->addFile($full); + } + + return $this->addDirectory($full, $recursive); + } + + //-------------------------------------------------------------------- + + /** + * Downloads and stages files from + * an array of URIs. + * + * @param string[] $uris + * + * @return $this + */ + public function addUris(array $uris) + { + foreach ($uris as $uri) + { + $this->addUri($uri); + } + + return $this; + } + + /** + * Downloads a file from the URI + * and adds it to the file list. + * + * @param string $uri Because HTTP\URI is stringable it will still be accepted + * + * @return $this + */ + public function addUri(string $uri) + { + // Figure out a good filename (using URI strips queries and fragments) + $file = $this->getScratch() . basename((new URI($uri))->getPath()); + + // Get the content and write it to the scratch space + $response = service('curlrequest')->get($uri); + write_file($file, $response->getBody()); + + return $this->addFile($file); + } + + //-------------------------------------------------------------------- + + /** + * Removes any files from the list that match + * the supplied pattern (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope A directory to limit the scope + * + * @return $this + */ + public function removePattern(string $pattern, string $scope = null) + { + if ($pattern === '') + { + return $this; + } + + // Start with all files or those in scope + $files = is_null($scope) + ? $this->files + : self::filterFiles($this->files, $scope); + + // Remove any files that match the pattern + return $this->removeFiles(self::matchFiles($files, $pattern)); + } + + /** + * Keeps only the files from the list that match + * the supplied pattern (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope A directory to limit the scope + * + * @return $this + */ + public function retainPattern(string $pattern, string $scope = null) + { + if ($pattern === '') + { + return $this; + } + + // Start with all files or those in scope + $files = is_null($scope) + ? $this->files + : self::filterFiles($this->files, $scope); + + // Match the pattern within the scoped files + $matched = self::matchFiles($files, $pattern); + + // ... and remove their inverse + return $this->removeFiles(array_diff($files, $matched)); + } + + //-------------------------------------------------------------------- + + /** + * Removes the destination and all its files and folders. + * + * @return $this + */ + public function wipe() + { + self::wipeDirectory($this->destination); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Copies all files into the destination. + * Does not create directory structure. + * + * @param bool $replace Whether to overwrite existing files. + * + * @return boolean Whether all files were copied successfully + */ + public function copy(bool $replace = true): bool + { + $this->errors = []; + + foreach ($this->getFiles() as $file) + { + $to = $this->destination . basename($file); + + try + { + self::safeCopyFile($file, $to, $replace); + } + catch (Throwable $e) + { + $this->errors[$file] = $e; + } + } + + return $this->errors === []; + } + + /** + * Merges all files into the destination. + * Creates a mirrored directory structure + * only for files from source. + * + * @param bool $replace Whether to overwrite existing files. + * + * @return boolean Whether all files were copied successfully + */ + public function merge(bool $replace = true): bool + { + $this->errors = []; + + // Get the file from source for special handling + $sourced = self::matchFiles($this->getFiles(), $this->source); + + // Handle everything else with a flat copy + $this->files = array_diff($this->files, $sourced); + $this->copy($replace); + + // Copy each sourced file to its relative destination + foreach ($sourced as $file) + { + // Resolve the destination path + $to = $this->destination . substr($file, strlen($this->source)); + + try + { + self::safeCopyFile($file, $to, $replace); + } + catch (Throwable $e) + { + $this->errors[$file] = $e; + } + } + + return $this->errors === []; + } +} diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php new file mode 100644 index 000000000000..8ef30ca90024 --- /dev/null +++ b/tests/_support/Publishers/TestPublisher.php @@ -0,0 +1,16 @@ +downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets'); + } +} From 18ae7eadac7c15896a4d6804b27a5ecc792befd1 Mon Sep 17 00:00:00 2001 From: MGatner Date: Mon, 24 May 2021 01:10:02 +0000 Subject: [PATCH 03/65] Add tests --- system/Publisher/Publisher.php | 18 +- tests/system/Publisher/PublisherInputTest.php | 472 ++++++++++++++++++ .../system/Publisher/PublisherOutputTest.php | 202 ++++++++ .../system/Publisher/PublisherSupportTest.php | 199 ++++++++ 4 files changed, 887 insertions(+), 4 deletions(-) create mode 100644 tests/system/Publisher/PublisherInputTest.php create mode 100644 tests/system/Publisher/PublisherOutputTest.php create mode 100644 tests/system/Publisher/PublisherSupportTest.php diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 8f599d3292b6..d7f797e00312 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -108,8 +108,8 @@ public static function discover($directory = 'Publishers'): array return []; } - // Loop over each file checking to see if it is a Primer - foreach ($files as $file) + // Loop over each file checking to see if it is a Publisher + foreach (array_unique($files) as $file) { $className = $locator->findQualifiedNameFromPath($file); @@ -228,8 +228,10 @@ private static function wipeDirectory(string $directory) $attempts = 10; while ((bool) $attempts && ! delete_files($directory, true, false, true)) { + // @codeCoverageIgnoreStart $attempts--; usleep(100000); // .1s + // @codeCoverageIgnoreEnd } @rmdir($directory); @@ -270,6 +272,13 @@ private static function safeCopyFile(string $from, string $to, bool $replace): v unlink($to); } + // Make sure the directory exists + $directory = pathinfo($to, PATHINFO_DIRNAME); + if (! is_dir($directory)) + { + mkdir($directory, 0775, true); + } + // Allow copy() to throw errors copy($from, $to); } @@ -311,10 +320,11 @@ public function __destruct() * This method should be reimplemented by * child classes intended for discovery. * - * @return void + * @return bool */ public function publish() { + return $this->addPath('/')->merge(true); } //-------------------------------------------------------------------- @@ -686,7 +696,7 @@ public function merge(bool $replace = true): bool $this->errors = []; // Get the file from source for special handling - $sourced = self::matchFiles($this->getFiles(), $this->source); + $sourced = self::filterFiles($this->getFiles(), $this->source); // Handle everything else with a flat copy $this->files = array_diff($this->files, $sourced); diff --git a/tests/system/Publisher/PublisherInputTest.php b/tests/system/Publisher/PublisherInputTest.php new file mode 100644 index 000000000000..553a92b14c53 --- /dev/null +++ b/tests/system/Publisher/PublisherInputTest.php @@ -0,0 +1,472 @@ +assertSame([], $this->getPrivateProperty($publisher, 'files')); + + $publisher->addFile($this->file); + $this->assertSame([$this->file], $this->getPrivateProperty($publisher, 'files')); + } + + public function testAddFileMissing() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); + + $publisher->addFile('TheHillsAreAlive.bmp'); + } + + public function testAddFileDirectory() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); + + $publisher->addFile($this->directory); + } + + public function testAddFiles() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->file, + ]; + + $publisher->addFiles($files); + $this->assertSame($files, $this->getPrivateProperty($publisher, 'files')); + } + + //-------------------------------------------------------------------- + + public function testGetFiles() + { + $publisher = new Publisher(); + $publisher->addFile($this->file); + + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testGetFilesSorts() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $publisher->addFiles($files); + + $this->assertSame(array_reverse($files), $publisher->getFiles()); + } + + public function testGetFilesUniques() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->file, + ]; + + $publisher->addFiles($files); + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testSetFiles() + { + $publisher = new Publisher(); + + $publisher->setFiles([$this->file]); + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testSetFilesInvalid() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); + + $publisher->setFiles(['flerb']); + } + + //-------------------------------------------------------------------- + + public function testRemoveFile() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $publisher->addFiles($files); + + $publisher->removeFile($this->file); + + $this->assertSame([$this->directory . 'apple.php'], $publisher->getFiles()); + } + + public function testRemoveFiles() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $publisher->addFiles($files); + + $publisher->removeFiles($files); + + $this->assertSame([], $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testAddDirectoryInvalid() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedDirectory', ['addDirectory'])); + + $publisher->addDirectory($this->file); + } + + public function testAddDirectory() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $publisher->addDirectory($this->directory); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddDirectoryRecursive() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddDirectories() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addDirectories([ + $this->directory, + SUPPORTPATH . 'Files/baker', + ]); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddDirectoriesRecursive() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $publisher->addDirectories([ + SUPPORTPATH . 'Files', + SUPPORTPATH . 'Log', + ], true); + + $this->assertSame($expected, $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testAddPathFile() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $publisher->addPath('baker/banana.php'); + + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testAddPathFileRecursiveDoesNothing() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $publisher->addPath('baker/banana.php', true); + + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testAddPathDirectory() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $publisher->addPath('able'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddPathDirectoryRecursive() + { + $publisher = new Publisher(SUPPORTPATH); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addPath('Files'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddPaths() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addPaths([ + 'able', + 'baker/banana.php', + ]); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddPathsRecursive() + { + $publisher = new Publisher(SUPPORTPATH); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $publisher->addPaths([ + 'Files', + 'Log', + ], true); + + $this->assertSame($expected, $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testAddUri() + { + $publisher = new Publisher(); + $publisher->addUri('https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/composer.json'); + + $scratch = $this->getPrivateProperty($publisher, 'scratch'); + + $this->assertSame([$scratch . 'composer.json'], $publisher->getFiles()); + } + + public function testAddUris() + { + $publisher = new Publisher(); + $publisher->addUris([ + 'https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/LICENSE', + 'https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/composer.json', + ]); + + $scratch = $this->getPrivateProperty($publisher, 'scratch'); + + $this->assertSame([$scratch . 'LICENSE', $scratch . 'composer.json'], $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testRemovePatternEmpty() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $publisher->getFiles(); + + $publisher->removePattern(''); + + $this->assertSame($files, $publisher->getFiles()); + } + + public function testRemovePatternRegex() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->removePattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRemovePatternPseudo() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->removePattern('*_*.php'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRemovePatternScope() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->removePattern('*.php', $this->directory); + + $this->assertSame($expected, $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testRetainPatternEmpty() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $publisher->getFiles(); + + $publisher->retainPattern(''); + + $this->assertSame($files, $publisher->getFiles()); + } + + public function testRetainPatternRegex() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $publisher->retainPattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRetainPatternPseudo() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + ]; + + $publisher->retainPattern('*_?.php'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRetainPatternScope() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->retainPattern('*_?.php', $this->directory); + + $this->assertSame($expected, $publisher->getFiles()); + } +} diff --git a/tests/system/Publisher/PublisherOutputTest.php b/tests/system/Publisher/PublisherOutputTest.php new file mode 100644 index 000000000000..5183d188d6b9 --- /dev/null +++ b/tests/system/Publisher/PublisherOutputTest.php @@ -0,0 +1,202 @@ +structure = [ + 'able' => [ + 'apple.php' => 'Once upon a midnight dreary', + 'bazam' => 'While I pondered weak and weary', + ], + 'boo' => [ + 'far' => 'Upon a tome of long-forgotten lore', + 'faz' => 'There came a tapping up on the door', + ], + 'AnEmptyFolder' => [], + 'simpleFile' => 'A tap-tap-tapping upon my door', + '.hidden' => 'There is no spoon', + ]; + + $this->root = vfsStream::setup('root', null, $this->structure); + } + + //-------------------------------------------------------------------- + + public function testCopy() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $publisher->addFile($this->file); + + $this->assertFileDoesNotExist($this->root->url() . '/banana.php'); + + $result = $publisher->copy(false); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/banana.php'); + } + + public function testCopyReplace() + { + $file = $this->directory . 'apple.php'; + $publisher = new Publisher($this->directory, $this->root->url() . '/able'); + $publisher->addFile($file); + + $this->assertFileExists($this->root->url() . '/able/apple.php'); + $this->assertFalse(same_file($file, $this->root->url() . '/able/apple.php')); + + $result = $publisher->copy(true); + + $this->assertTrue($result); + $this->assertTrue(same_file($file, $this->root->url() . '/able/apple.php')); + } + + public function testCopyIgnoresSame() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $publisher->addFile($this->file); + + copy($this->file, $this->root->url() . '/banana.php'); + + $result = $publisher->copy(false); + $this->assertTrue($result); + + $result = $publisher->copy(true); + $this->assertTrue($result); + } + + public function testCopyIgnoresCollision() + { + $publisher = new Publisher($this->directory, $this->root->url()); + + mkdir($this->root->url() . '/banana.php'); + + $result = $publisher->addFile($this->file)->copy(false); + $errors = $publisher->getErrors(); + + $this->assertTrue($result); + $this->assertSame([], $errors); + } + + public function testCopyCollides() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $expected = lang('Publisher.collision', ['dir', $this->file, $this->root->url() . '/banana.php']); + + mkdir($this->root->url() . '/banana.php'); + + $result = $publisher->addFile($this->file)->copy(true); + $errors = $publisher->getErrors(); + + $this->assertFalse($result); + $this->assertCount(1, $errors); + $this->assertSame([$this->file], array_keys($errors)); + $this->assertSame($expected, $errors[$this->file]->getMessage()); + } + + //-------------------------------------------------------------------- + + public function testMerge() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + + $this->assertFileDoesNotExist($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryDoesNotExist($this->root->url() . '/baker'); + + $result = $publisher->addPath('/')->merge(false); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryExists($this->root->url() . '/baker'); + } + + public function testMergeReplace() + { + $this->assertFalse(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + + $result = $publisher->addPath('/')->merge(true); + + $this->assertTrue($result); + $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + } + + public function testMergeCollides() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = lang('Publisher.collision', ['dir', $this->directory . 'fig_3.php', $this->root->url() . '/able/fig_3.php']); + + mkdir($this->root->url() . '/able/fig_3.php'); + + $result = $publisher->addPath('/')->merge(true); + $errors = $publisher->getErrors(); + + $this->assertFalse($result); + $this->assertCount(1, $errors); + $this->assertSame([$this->directory . 'fig_3.php'], array_keys($errors)); + $this->assertSame($expected, $errors[$this->directory . 'fig_3.php']->getMessage()); + } + + //-------------------------------------------------------------------- + + public function testPublish() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + + $result = $publisher->publish(); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryExists($this->root->url() . '/baker'); + $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + } +} diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php new file mode 100644 index 000000000000..5592c02fc4ca --- /dev/null +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -0,0 +1,199 @@ +assertCount(1, $result); + $this->assertInstanceOf(TestPublisher::class, $result[0]); + } + + public function testDiscoverNothing() + { + $result = Publisher::discover('Nothing'); + + $this->assertSame([], $result); + } + + public function testDiscoverStores() + { + $publisher = Publisher::discover()[0]; + $publisher->addFile($this->file); + + $result = Publisher::discover(); + $this->assertSame($publisher, $result[0]); + $this->assertSame([$this->file], $result[0]->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testResolveDirectoryDirectory() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($this->directory)); + } + + public function testResolveDirectoryFile() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedDirectory', ['invokeArgs'])); + + $method($this->file); + } + + public function testResolveDirectorySymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->directory, $link); + + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($link)); + + unlink($link); + } + + //-------------------------------------------------------------------- + + public function testResolveFileFile() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); + + $this->assertSame($this->file, $method($this->file)); + } + + public function testResolveFileSymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->file, $link); + + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); + + $this->assertSame($this->file, $method($link)); + + unlink($link); + } + + public function testResolveFileDirectory() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['invokeArgs'])); + + $method($this->directory); + } + + //-------------------------------------------------------------------- + + public function testGetScratch() + { + $publisher = new Publisher(); + $this->assertNull($this->getPrivateProperty($publisher, 'scratch')); + + $method = $this->getPrivateMethodInvoker($publisher, 'getScratch'); + $scratch = $method(); + + $this->assertIsString($scratch); + $this->assertDirectoryExists($scratch); + $this->assertDirectoryIsWritable($scratch); + $this->assertNotNull($this->getPrivateProperty($publisher, 'scratch')); + + // Directory and contents should be removed on __destruct() + $file = $scratch . 'obvious_statement.txt'; + file_put_contents($file, 'Bananas are a most peculiar fruit'); + + $publisher->__destruct(); + + $this->assertFileDoesNotExist($file); + $this->assertDirectoryDoesNotExist($scratch); + } + + public function testGetErrors() + { + $publisher = new Publisher(); + $this->assertSame([], $publisher->getErrors()); + + $expected = [ + $this->file => PublisherException::forCollision($this->file, $this->file), + ]; + + $this->setPrivateProperty($publisher, 'errors', $expected); + + $this->assertSame($expected, $publisher->getErrors()); + } + + //-------------------------------------------------------------------- + + public function testWipeDirectory() + { + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $this->assertDirectoryExists($directory); + + $method = $this->getPrivateMethodInvoker(Publisher::class, 'wipeDirectory'); + $method($directory); + + $this->assertDirectoryDoesNotExist($directory); + } + + public function testWipeIgnoresFiles() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'wipeDirectory'); + $method($this->file); + + $this->assertFileExists($this->file); + } + + public function testWipe() + { + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $this->assertDirectoryExists($directory); + + $publisher = new Publisher($this->directory, $directory); + $publisher->wipe(); + + $this->assertDirectoryDoesNotExist($directory); + } +} From 116fc82effa883686c3b819c9e44ee8adc767252 Mon Sep 17 00:00:00 2001 From: MGatner Date: Mon, 24 May 2021 15:51:16 -0400 Subject: [PATCH 04/65] Apply suggestions from code review Co-authored-by: Mostafa Khudair <59371810+mostafakhudair@users.noreply.github.com> --- system/Language/en/Publisher.php | 4 +- .../Exceptions/PublisherException.php | 10 +- system/Publisher/Publisher.php | 125 ++++++++---------- 3 files changed, 60 insertions(+), 79 deletions(-) diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php index ada599074efe..cb5ce81f10a4 100644 --- a/system/Language/en/Publisher.php +++ b/system/Language/en/Publisher.php @@ -11,7 +11,7 @@ // Publisher language settings return [ - 'expectedFile' => 'Publisher::{0} expects a valid file.', - 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', + 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', + 'expectedFile' => 'Publisher::{0} expects a valid file.', ]; diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php index 23a4b54d3e57..81f9bb4b7174 100644 --- a/system/Publisher/Exceptions/PublisherException.php +++ b/system/Publisher/Exceptions/PublisherException.php @@ -15,6 +15,11 @@ class PublisherException extends FrameworkException { + public static function forCollision(string $from, string $to) + { + return new static(lang('Publisher.collision', [filetype($to), $from, $to])); + } + public static function forExpectedDirectory(string $caller) { return new static(lang('Publisher.expectedDirectory', [$caller])); @@ -24,9 +29,4 @@ public static function forExpectedFile(string $caller) { return new static(lang('Publisher.expectedFile', [$caller])); } - - public static function forCollision(string $from, string $to) - { - return new static(lang('Publisher.collision', [filetype($to), $from, $to])); - } } diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index d7f797e00312..b2e9de8d577b 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -20,24 +20,18 @@ /** * Publisher Class * - * Publishers read in file paths from a variety - * of sources and copy the files out to different - * destinations. - * This class acts both as a base for individual - * publication directives as well as the mode of - * discovery for said instances. - * In this class a "file" is a full path to a - * verified file while a "path" is relative to - * to its source or destination and may indicate - * either a file or directory fo unconfirmed - * existence. - * Class failures throw a PublisherException, - * but some underlying methods may percolate - * different exceptions, like FileException, - * FileNotFoundException, InvalidArgumentException. - * Write operations will catch all errors in the - * file-specific $errors property to minimize - * impact of partial batch operations. + * Publishers read in file paths from a variety of sources and copy + * the files out to different destinations. This class acts both as + * a base for individual publication directives as well as the mode + * of discovery for said instances. In this class a "file" is a full + * path to a verified file while a "path" is relative to its source + * or destination and may indicate either a file or directory of + * unconfirmed existence. + * class failures throw the PublisherException, but some underlying + * methods may percolate different exceptions, like FileException, + * FileNotFoundException or InvalidArgumentException. + * Write operations will catch all errors in the file-specific + * $errors property to minimize impact of partial batch operations. */ class Publisher { @@ -90,14 +84,17 @@ class Publisher * Discovers and returns all Publishers * in the specified namespace directory. * + * @param string $directory + * * @return self[] */ - public static function discover($directory = 'Publishers'): array + public static function discover(string $directory = 'Publishers'): array { if (isset(self::$discovered[$directory])) { return self::$discovered[$directory]; } + self::$discovered[$directory] = []; /** @var FileLocator $locator */ @@ -145,8 +142,7 @@ private static function resolveDirectory(string $directory): string } /** - * Resolves a full path and verifies - * it is an actual file. + * Resolves a full path and verifies it is an actual file. * * @param string $file * @@ -184,11 +180,10 @@ private static function filterFiles(array $files, string $directory): array } /** - * Returns any files whose basename matches - * the given pattern. + * Returns any files whose `basename` matches the given pattern. * - * @param string[] $files - * @param string $pattern Regex or pseudo-regex string + * @param array $files + * @param string $pattern Regex or pseudo-regex string * * @return string[] */ @@ -239,13 +234,12 @@ private static function wipeDirectory(string $directory) } /** - * Copies a file with directory creation - * and identical file awareness. + * Copies a file with directory creation and identical file awareness. * Intentionally allows errors. * - * @param string $from - * @param string $to - * @param bool $replace + * @param string $from + * @param string $to + * @param boolean $replace * * @return void * @@ -301,8 +295,7 @@ public function __construct(string $source = null, string $destination = null) } /** - * Cleans up any temporary files - * in the scratch space. + * Cleans up any temporary files in the scratch space. */ public function __destruct() { @@ -315,14 +308,13 @@ public function __destruct() } /** - * Reads in file sources and copies out - * the files to their destinations. - * This method should be reimplemented by - * child classes intended for discovery. + * Reads files in the sources and copies them out to their destinations. + * This method should be reimplemented by child classes intended for + * discovery. * - * @return bool + * @return boolean */ - public function publish() + public function publish(): bool { return $this->addPath('/')->merge(true); } @@ -347,8 +339,7 @@ protected function getScratch(): string } /** - * Returns any errors from the last - * write operation. + * Returns errors from the last write operation if any. * * @return array */ @@ -358,8 +349,7 @@ public function getErrors(): array } /** - * Optimizes and returns the - * current file list. + * Optimizes and returns the current file list. * * @return string[] */ @@ -372,11 +362,10 @@ public function getFiles(): array } /** - * Sets the file list directly. - * Files are still subject to verification. + * Sets the file list directly, files are still subject to verification. * This works as a "reset" method with []. * - * @param string[] $files A new file list to use + * @param string[] $files The new file list to use * * @return $this */ @@ -407,8 +396,7 @@ public function addFiles(array $files) } /** - * Verifies and adds a single file - * to the file list. + * Verifies and adds a single file to the file list. * * @param string $file * @@ -469,11 +457,10 @@ public function addDirectories(array $directories, bool $recursive = false) } /** - * Verifies and adds all files - * from a directory. + * Verifies and adds all files from a directory. * - * @param string $directory - * @param bool $recursive + * @param string $directory + * @param boolean $recursive * * @return $this */ @@ -520,8 +507,8 @@ public function addPaths(array $paths, bool $recursive = true) /** * Adds a single path to the file list. * - * @param string $path - * @param bool $recursive + * @param string $path + * @param boolean $recursive * * @return $this */ @@ -563,8 +550,7 @@ public function addUris(array $uris) } /** - * Downloads a file from the URI - * and adds it to the file list. + * Downloads a file from the URI, and adds it to the file list. * * @param string $uri Because HTTP\URI is stringable it will still be accepted * @@ -576,8 +562,7 @@ public function addUri(string $uri) $file = $this->getScratch() . basename((new URI($uri))->getPath()); // Get the content and write it to the scratch space - $response = service('curlrequest')->get($uri); - write_file($file, $response->getBody()); + write_file($file, service('curlrequest')->get($uri)->getBody()); return $this->addFile($file); } @@ -585,11 +570,11 @@ public function addUri(string $uri) //-------------------------------------------------------------------- /** - * Removes any files from the list that match - * the supplied pattern (within the optional scope). + * Removes any files from the list that match the supplied pattern + * (within the optional scope). * - * @param string $pattern Regex or pseudo-regex string - * @param string|null $scope A directory to limit the scope + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope The directory to limit the scope * * @return $this */ @@ -601,9 +586,7 @@ public function removePattern(string $pattern, string $scope = null) } // Start with all files or those in scope - $files = is_null($scope) - ? $this->files - : self::filterFiles($this->files, $scope); + $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); // Remove any files that match the pattern return $this->removeFiles(self::matchFiles($files, $pattern)); @@ -611,9 +594,9 @@ public function removePattern(string $pattern, string $scope = null) /** * Keeps only the files from the list that match - * the supplied pattern (within the optional scope). + * (within the optional scope). * - * @param string $pattern Regex or pseudo-regex string + * @param string $pattern Regex or pseudo-regex string * @param string|null $scope A directory to limit the scope * * @return $this @@ -654,10 +637,9 @@ public function wipe() //-------------------------------------------------------------------- /** - * Copies all files into the destination. - * Does not create directory structure. + * Copies all files into the destination, does not create directory structure. * - * @param bool $replace Whether to overwrite existing files. + * @param boolean $replace Whether to overwrite existing files. * * @return boolean Whether all files were copied successfully */ @@ -684,10 +666,9 @@ public function copy(bool $replace = true): bool /** * Merges all files into the destination. - * Creates a mirrored directory structure - * only for files from source. + * Creates a mirrored directory structure only for files from source. * - * @param bool $replace Whether to overwrite existing files. + * @param boolean $replace Whether to overwrite existing files. * * @return boolean Whether all files were copied successfully */ From 865c73a8e414db8f182242783e10104380249aff Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 25 May 2021 16:13:09 +0000 Subject: [PATCH 05/65] Apply additional suggestions --- system/Publisher/Publisher.php | 48 ++++++++------------- tests/_support/Publishers/TestPublisher.php | 2 +- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index b2e9de8d577b..640dd779756e 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -43,8 +43,7 @@ class Publisher private static $discovered = []; /** - * Directory to use for methods - * that need temporary storage. + * Directory to use for methods that need temporary storage. * Created on-the-fly as needed. * * @var string|null @@ -59,8 +58,7 @@ class Publisher private $files = []; /** - * Exceptions for specific files - * from the last write operation. + * Exceptions for specific files from the last write operation. * * @var array */ @@ -81,8 +79,7 @@ class Publisher protected $destination = FCPATH; /** - * Discovers and returns all Publishers - * in the specified namespace directory. + * Discovers and returns all Publishers in the specified namespace directory. * * @param string $directory * @@ -115,6 +112,7 @@ public static function discover(string $directory = 'Publishers'): array self::$discovered[$directory][] = new $className(); } } + sort(self::$discovered[$directory]); return self::$discovered[$directory]; @@ -123,8 +121,7 @@ public static function discover(string $directory = 'Publishers'): array //-------------------------------------------------------------------- /** - * Resolves a full path and verifies - * it is an actual directory. + * Resolves a full path and verifies it is an actual directory. * * @param string $directory * @@ -162,11 +159,10 @@ private static function resolveFile(string $file): string //-------------------------------------------------------------------- /** - * Filters an array of files, removing files not - * part of the given directory (recursive). + * Removes files that are not part of the given directory (recursive). * * @param string[] $files - * @param string $directory + * @param string $directory * * @return string[] */ @@ -207,15 +203,14 @@ private static function matchFiles(array $files, string $pattern) //-------------------------------------------------------------------- - /** - * Removes a directory and all its files - * and subdirectories. + /* + * Removes a directory and all its files and subdirectories. * * @param string $directory * * @return void */ - private static function wipeDirectory(string $directory) + private static function wipeDirectory(string $directory): void { if (is_dir($directory)) { @@ -267,8 +262,7 @@ private static function safeCopyFile(string $from, string $to, bool $replace): v } // Make sure the directory exists - $directory = pathinfo($to, PATHINFO_DIRNAME); - if (! is_dir($directory)) + if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) { mkdir($directory, 0775, true); } @@ -280,8 +274,7 @@ private static function safeCopyFile(string $from, string $to, bool $replace): v //-------------------------------------------------------------------- /** - * Loads the helper and verifies the - * source and destination directories. + * Loads the helper and verifies the source and destination directories. * * @param string|null $source * @param string|null $destination @@ -322,8 +315,7 @@ public function publish(): bool //-------------------------------------------------------------------- /** - * Returns the temporary workspace, - * creating it if necessary. + * Returns the temporary workspace, creating it if necessary. * * @return string */ @@ -532,8 +524,7 @@ public function addPath(string $path, bool $recursive = true) //-------------------------------------------------------------------- /** - * Downloads and stages files from - * an array of URIs. + * Downloads and stages files from an array of URIs. * * @param string[] $uris * @@ -609,15 +600,10 @@ public function retainPattern(string $pattern, string $scope = null) } // Start with all files or those in scope - $files = is_null($scope) - ? $this->files - : self::filterFiles($this->files, $scope); - - // Match the pattern within the scoped files - $matched = self::matchFiles($files, $pattern); + $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); - // ... and remove their inverse - return $this->removeFiles(array_diff($files, $matched)); + // Matches the pattern within the scoped files and remove their inverse. + return $this->removeFiles(array_diff($files, self::matchFiles($files, $pattern))); } //-------------------------------------------------------------------- diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index 8ef30ca90024..fdb3547478d1 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -9,7 +9,7 @@ class TestPublisher extends Publisher /** * Runs the defined Operations. */ - public function publish() + public function publish(): bool { $this->downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets'); } From 3987bff099567df58112226b088b4860b649bac6 Mon Sep 17 00:00:00 2001 From: MGatner Date: Thu, 27 May 2021 20:15:37 +0000 Subject: [PATCH 06/65] Add Guide, tweak class --- system/Publisher/Publisher.php | 95 +++- tests/_support/Publishers/TestPublisher.php | 55 ++- .../system/Publisher/PublisherOutputTest.php | 26 +- .../system/Publisher/PublisherSupportTest.php | 17 +- user_guide_src/source/libraries/publisher.rst | 437 ++++++++++++++++++ 5 files changed, 598 insertions(+), 32 deletions(-) create mode 100644 user_guide_src/source/libraries/publisher.rst diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 640dd779756e..b9e5cca24e90 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -15,6 +15,7 @@ use CodeIgniter\Files\File; use CodeIgniter\HTTP\URI; use CodeIgniter\Publisher\Exceptions\PublisherException; +use RuntimeException; use Throwable; /** @@ -64,6 +65,13 @@ class Publisher */ private $errors = []; + /** + * List of file published curing the last write operation. + * + * @var string[] + */ + private $published = []; + /** * Base path to use for the source. * @@ -85,7 +93,7 @@ class Publisher * * @return self[] */ - public static function discover(string $directory = 'Publishers'): array + final public static function discover(string $directory = 'Publishers'): array { if (isset(self::$discovered[$directory])) { @@ -301,7 +309,7 @@ public function __destruct() } /** - * Reads files in the sources and copies them out to their destinations. + * Reads files from the sources and copies them out to their destinations. * This method should be reimplemented by child classes intended for * discovery. * @@ -309,17 +317,42 @@ public function __destruct() */ public function publish(): bool { + if ($this->source === ROOTPATH && $this->destination === FCPATH) + { + throw new RuntimeException('Child classes of Publisher should provide their own source and destination or publish method.'); + } + return $this->addPath('/')->merge(true); } //-------------------------------------------------------------------- + /** + * Returns the source directory. + * + * @return string + */ + final public function getSource(): string + { + return $this->source; + } + + /** + * Returns the destination directory. + * + * @return string + */ + final public function getDestination(): string + { + return $this->destination; + } + /** * Returns the temporary workspace, creating it if necessary. * * @return string */ - protected function getScratch(): string + final public function getScratch(): string { if (is_null($this->scratch)) { @@ -335,17 +368,27 @@ protected function getScratch(): string * * @return array */ - public function getErrors(): array + final public function getErrors(): array { return $this->errors; } + /** + * Returns the files published by the last write operation. + * + * @return string[] + */ + final public function getPublished(): array + { + return $this->published; + } + /** * Optimizes and returns the current file list. * * @return string[] */ - public function getFiles(): array + final public function getFiles(): array { $this->files = array_unique($this->files, SORT_STRING); sort($this->files, SORT_STRING); @@ -353,6 +396,8 @@ public function getFiles(): array return $this->files; } + //-------------------------------------------------------------------- + /** * Sets the file list directly, files are still subject to verification. * This works as a "reset" method with []. @@ -361,15 +406,13 @@ public function getFiles(): array * * @return $this */ - public function setFiles(array $files) + final public function setFiles(array $files) { $this->files = []; return $this->addFiles($files); } - //-------------------------------------------------------------------- - /** * Verifies and adds files to the list. * @@ -377,7 +420,7 @@ public function setFiles(array $files) * * @return $this */ - public function addFiles(array $files) + final public function addFiles(array $files) { foreach ($files as $file) { @@ -394,7 +437,7 @@ public function addFiles(array $files) * * @return $this */ - public function addFile(string $file) + final public function addFile(string $file) { $this->files[] = self::resolveFile($file); @@ -408,7 +451,7 @@ public function addFile(string $file) * * @return $this */ - public function removeFiles(array $files) + final public function removeFiles(array $files) { $this->files = array_diff($this->files, $files); @@ -422,7 +465,7 @@ public function removeFiles(array $files) * * @return $this */ - public function removeFile(string $file) + final public function removeFile(string $file) { return $this->removeFiles([$file]); } @@ -438,7 +481,7 @@ public function removeFile(string $file) * * @return $this */ - public function addDirectories(array $directories, bool $recursive = false) + final public function addDirectories(array $directories, bool $recursive = false) { foreach ($directories as $directory) { @@ -456,7 +499,7 @@ public function addDirectories(array $directories, bool $recursive = false) * * @return $this */ - public function addDirectory(string $directory, bool $recursive = false) + final public function addDirectory(string $directory, bool $recursive = false) { $directory = self::resolveDirectory($directory); @@ -486,7 +529,7 @@ public function addDirectory(string $directory, bool $recursive = false) * * @return $this */ - public function addPaths(array $paths, bool $recursive = true) + final public function addPaths(array $paths, bool $recursive = true) { foreach ($paths as $path) { @@ -504,7 +547,7 @@ public function addPaths(array $paths, bool $recursive = true) * * @return $this */ - public function addPath(string $path, bool $recursive = true) + final public function addPath(string $path, bool $recursive = true) { $full = $this->source . $path; @@ -530,7 +573,7 @@ public function addPath(string $path, bool $recursive = true) * * @return $this */ - public function addUris(array $uris) + final public function addUris(array $uris) { foreach ($uris as $uri) { @@ -547,7 +590,7 @@ public function addUris(array $uris) * * @return $this */ - public function addUri(string $uri) + final public function addUri(string $uri) { // Figure out a good filename (using URI strips queries and fragments) $file = $this->getScratch() . basename((new URI($uri))->getPath()); @@ -569,7 +612,7 @@ public function addUri(string $uri) * * @return $this */ - public function removePattern(string $pattern, string $scope = null) + final public function removePattern(string $pattern, string $scope = null) { if ($pattern === '') { @@ -592,7 +635,7 @@ public function removePattern(string $pattern, string $scope = null) * * @return $this */ - public function retainPattern(string $pattern, string $scope = null) + final public function retainPattern(string $pattern, string $scope = null) { if ($pattern === '') { @@ -613,7 +656,7 @@ public function retainPattern(string $pattern, string $scope = null) * * @return $this */ - public function wipe() + final public function wipe() { self::wipeDirectory($this->destination); @@ -629,9 +672,9 @@ public function wipe() * * @return boolean Whether all files were copied successfully */ - public function copy(bool $replace = true): bool + final public function copy(bool $replace = true): bool { - $this->errors = []; + $this->errors = $this->published = []; foreach ($this->getFiles() as $file) { @@ -640,6 +683,7 @@ public function copy(bool $replace = true): bool try { self::safeCopyFile($file, $to, $replace); + $this->published[] = $to; } catch (Throwable $e) { @@ -658,9 +702,9 @@ public function copy(bool $replace = true): bool * * @return boolean Whether all files were copied successfully */ - public function merge(bool $replace = true): bool + final public function merge(bool $replace = true): bool { - $this->errors = []; + $this->errors = $this->published = []; // Get the file from source for special handling $sourced = self::filterFiles($this->getFiles(), $this->source); @@ -678,6 +722,7 @@ public function merge(bool $replace = true): bool try { self::safeCopyFile($file, $to, $replace); + $this->published[] = $to; } catch (Throwable $e) { diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index fdb3547478d1..9192c3899b83 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -4,13 +4,62 @@ use CodeIgniter\Publisher\Publisher; -class TestPublisher extends Publisher +final class TestPublisher extends Publisher { /** - * Runs the defined Operations. + * Fakes an error on the given file. + * + * @return $this + */ + public static function setError(string $file) + { + self::$error = $file; + } + + /** + * A file to cause an error + * + * @var string + */ + private static $error = ''; + + /** + * Base path to use for the source. + * + * @var string + */ + protected $source = SUPPORTPATH . 'Files'; + + /** + * Base path to use for the destination. + * + * @var string + */ + protected $destination = WRITEPATH; + + /** + * Fakes a publish event so no files are actually copied. */ public function publish(): bool { - $this->downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets'); + $this->errors = $this->published = []; + + $this->addPath(''); + + // Copy each sourced file to its relative destination + foreach ($this->getFiles() as $file) + { + if ($file === self::$error) + { + $this->errors[$file] = new RuntimeException('Have an error, dear.'); + } + else + { + // Resolve the destination path + $this->published[] = $this->destination . substr($file, strlen($this->source)); + } + } + + return $this->errors === []; } } diff --git a/tests/system/Publisher/PublisherOutputTest.php b/tests/system/Publisher/PublisherOutputTest.php index 5183d188d6b9..0f416440b803 100644 --- a/tests/system/Publisher/PublisherOutputTest.php +++ b/tests/system/Publisher/PublisherOutputTest.php @@ -112,6 +112,7 @@ public function testCopyIgnoresSame() $result = $publisher->copy(true); $this->assertTrue($result); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); } public function testCopyIgnoresCollision() @@ -121,10 +122,10 @@ public function testCopyIgnoresCollision() mkdir($this->root->url() . '/banana.php'); $result = $publisher->addFile($this->file)->copy(false); - $errors = $publisher->getErrors(); $this->assertTrue($result); - $this->assertSame([], $errors); + $this->assertSame([], $publisher->getErrors()); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); } public function testCopyCollides() @@ -140,6 +141,7 @@ public function testCopyCollides() $this->assertFalse($result); $this->assertCount(1, $errors); $this->assertSame([$this->file], array_keys($errors)); + $this->assertSame([], $publisher->getPublished()); $this->assertSame($expected, $errors[$this->file]->getMessage()); } @@ -148,6 +150,12 @@ public function testCopyCollides() public function testMerge() { $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; $this->assertFileDoesNotExist($this->root->url() . '/able/fig_3.php'); $this->assertDirectoryDoesNotExist($this->root->url() . '/baker'); @@ -157,23 +165,36 @@ public function testMerge() $this->assertTrue($result); $this->assertFileExists($this->root->url() . '/able/fig_3.php'); $this->assertDirectoryExists($this->root->url() . '/baker'); + $this->assertSame($expected, $publisher->getPublished()); } public function testMergeReplace() { $this->assertFalse(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; $result = $publisher->addPath('/')->merge(true); $this->assertTrue($result); $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + $this->assertSame($expected, $publisher->getPublished()); } public function testMergeCollides() { $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); $expected = lang('Publisher.collision', ['dir', $this->directory . 'fig_3.php', $this->root->url() . '/able/fig_3.php']); + $published = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; mkdir($this->root->url() . '/able/fig_3.php'); @@ -183,6 +204,7 @@ public function testMergeCollides() $this->assertFalse($result); $this->assertCount(1, $errors); $this->assertSame([$this->directory . 'fig_3.php'], array_keys($errors)); + $this->assertSame($published, $publisher->getPublished()); $this->assertSame($expected, $errors[$this->directory . 'fig_3.php']->getMessage()); } diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index 5592c02fc4ca..f17be3460432 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -126,13 +126,26 @@ public function testResolveFileDirectory() //-------------------------------------------------------------------- + public function testGetSource() + { + $publisher = new Publisher(ROOTPATH); + + $this->assertSame(ROOTPATH, $publisher->getSource()); + } + + public function testGetDestination() + { + $publisher = new Publisher(ROOTPATH, SUPPORTPATH); + + $this->assertSame(SUPPORTPATH, $publisher->getDestination()); + } + public function testGetScratch() { $publisher = new Publisher(); $this->assertNull($this->getPrivateProperty($publisher, 'scratch')); - $method = $this->getPrivateMethodInvoker($publisher, 'getScratch'); - $scratch = $method(); + $scratch = $publisher->getScratch(); $this->assertIsString($scratch); $this->assertDirectoryExists($scratch); diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst new file mode 100644 index 000000000000..ef97e0b52f95 --- /dev/null +++ b/user_guide_src/source/libraries/publisher.rst @@ -0,0 +1,437 @@ +######### +Publisher +######### + +The Publisher library provides a means to copy files within a project using robust detection and error checking. + +.. contents:: + :local: + :depth: 2 + +******************* +Loading the Library +******************* + +Because Publisher instances are specific to their source and destination this library is not available +through ``Services`` but should be instantiated or extended directly. E.g. + + $publisher = new \CodeIgniter\Publisher\Publisher(); + +***************** +Concept and Usage +***************** + +``Publisher`` solves a handful of common problems when working within a backend framework: + +* How do I maintain project assets with version dependencies? +* How do I manage uploads and other "dynamic" files that need to be web accessible? +* How can I update my project when the framework or modules change? +* How can components inject new content into existing projects? + +At its most basic, publishing amounts to copying a file or files into a project. ``Publisher`` uses fluent-style +command chaining to read, filter, and process input files, then copies or merges them into the target destination. +You may use ``Publisher`` on demand in your Controllers or other components, or you may stage publications by extending +the class and leveraging its discovery with ``spark publish``. + +On Demand +========= + +Access ``Publisher`` directly by instantiating a new instance of the class:: + + $publisher = new \CodeIgniter\Publisher\Publisher(); + +By default the source and destination will be set to ``ROOTPATH`` and ``FCPATH`` respectively, giving ``Publisher`` +easy access to take any file from your project and make it web-accessible. Alternatively you may pass a new source +or source and destination into the constructor:: + + $vendorPublisher = new Publisher(ROOTPATH . 'vendor'); + $filterPublisher = new Publisher('/path/to/module/Filters', APPPATH . 'Filters'); + +Once the source and destination are set you may start adding relative input files:: + + $frameworkPublisher = new Publisher(ROOTPATH . 'vendor/codeigniter4/codeigniter4'); + + // All "path" commands are relative to $source + $frameworkPublisher->addPath('app/Config/Cookie.php'); + + // You may also add from outside the source, but the files will not be merged into subdirectories + $frameworkPublisher->addFiles([ + '/opt/mail/susan', + '/opt/mail/ubuntu', + ]); + $frameworkPublisher->addDirectory(SUPPORTPATH . 'Images'); + +Once all the files are staged use one of the output commands (**copy()** or **merge()**) to process the staged files +to their destination(s):: + + // Place all files into $destination + $frameworkPublisher->copy(); + + // Place all files into $destination, overwriting existing files + $frameworkPublisher->copy(true); + + // Place files into their relative $destination directories, overwriting and saving the boolean result + $result = $frameworkPublisher->merge(true); + +See the Library Reference for a full description of available methods. + +Automation and Discovery +======================== + +You may have regular publication tasks embedded as part of your application deployment or upkeep. ``Publisher`` leverages +the powerful ``Autoloader`` to locate any child classes primed for publication:: + + use CodeIgniter\CLI\CLI; + use CodeIgniter\Publisher\Publisher; + + foreach (Publisher::discover() as $publisher) + { + $result = $publisher->publish(); + + if ($result === false) + { + CLI::write(get_class($publisher) . ' failed to publish!', 'red'); + } + } + +By default ``discover()`` will search for the "Publishers" directory across all namespaces, but you may specify a +different directory and it will return any child classes found:: + + $memePublishers = Publisher::discover('CatGIFs'); + +Most of the time you will not need to handle your own discovery, just use the provided "publish" command:: + + > php spark publish + +By default on your class extension ``publish()`` will add all files from your ``$source`` and merge them +out to your destination, overwriting on collision. + +******** +Examples +******** + +Here are a handful of example use cases and their implementations to help you get started publishing. + +File Sync Example +================= + +You want to display a "photo of the day" image on your homepage. You have a feed for daily photos but you +need to get the actual file into a browsable location in your project at **public/images/daily_photo.jpg**. +You can set up :doc:`Custom Command ` to run daily that will handle this for you:: + + namespace App\Commands; + + use CodeIgniter\CLI\BaseCommand; + use CodeIgniter\Publisher\Publisher; + use Throwable; + + class DailyPhoto extends BaseCommand + { + protected $group = 'Publication'; + protected $name = 'publish:daily'; + protected $description = 'Publishes the latest daily photo to the homepage.'; + + public function run(array $params) + { + $publisher = new Publisher('/path/to/photos/', FCPATH . 'assets/images'); + + try + { + $publisher->addPath('daily_photo.jpg')->copy($replace = true); + } + catch (Throwable $e) + { + $this->showError($e); + } + } + } + +Now running ``spark publish:daily`` will keep your homepage's image up-to-date. What if the photo is +coming from an external API? You can use ``addUri()`` in place of ``addPath()`` to download the remote +resource and publish it out instead:: + + $publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy($replace = true); + +Asset Dependencies Example +========================== + +You want to integrate the frontend library "Bootstrap" into your project, but the frequent updates makes it a hassle +to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending +``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: + + namespace App\Publishers; + + use CodeIgniter\Publisher\Publisher; + + class BootstrapPublisher extends Publisher + { + /** + * Tell Publisher where to get the files. + * Since we will use Composer to download + * them we point to the "vendor" directory. + * + * @var string + */ + protected $source = 'vendor/twbs/bootstrap/'; + + /** + * FCPATH is always the default destination, + * but we may want them to go in a sub-folder + * to keep things organized. + * + * @var string + */ + protected $destination = FCPATH . 'bootstrap'; + + /** + * Use the "publish" method to indicate that this + * class is ready to be discovered and automated. + * + * @return boolean + */ + public function publish(): bool + { + return $this + // Add all the files relative to $source + ->addPath('dist') + + // Indicate we only want the minimized versions + ->retainPattern('*.min.*) + + // Merge-and-replace to retain the original directory structure + ->merge(true); + } + +Now add the dependency via Composer and call ``spark publish`` to run the publication:: + + > composer require twbs/bootstrap + > php spark publish + +... and you'll end up with something like this: + + public/.htaccess + public/favicon.ico + public/index.php + public/robots.txt + public/ + bootstrap/ + css/ + bootstrap.min.css + bootstrap-utilities.min.css.map + bootstrap-grid.min.css + bootstrap.rtl.min.css + bootstrap.min.css.map + bootstrap-reboot.min.css + bootstrap-utilities.min.css + bootstrap-reboot.rtl.min.css + bootstrap-grid.min.css.map + js/ + bootstrap.esm.min.js + bootstrap.bundle.min.js.map + bootstrap.bundle.min.js + bootstrap.min.js + bootstrap.esm.min.js.map + bootstrap.min.js.map + +Module Deployment Example +========================= + +You want to allow developers using your popular authentication module the ability to expand on the default behavior +of your Migration, Controller, and Model. You can create your own module "publish" command to inject these components +into an application for use:: + + namespace Math\Auth\Commands; + + use CodeIgniter\CLI\BaseCommand; + use CodeIgniter\Publisher\Publisher; + use Throwable; + + class Publish extends BaseCommand + { + protected $group = 'Auth'; + protected $name = 'auth:publish'; + protected $description = 'Publish Auth components into the current application.'; + + public function run(array $params) + { + // Use the Autoloader to figure out the module path + $source = service('autoloader')->getNamespace('Math\\Auth'); + + $publisher = new Publisher($source, APPATH); + + try + { + // Add only the desired components + $publisher->addPaths([ + 'Controllers', + 'Database/Migrations', + 'Models', + ])->merge(false); // Be careful not to overwrite anything + } + catch (Throwable $e) + { + $this->showError($e); + return; + } + + // If publication succeeded then update namespaces + foreach ($publisher->getFiles as $original) + { + // Get the location of the new file + $file = str_replace($source, APPPATH, $original); + + // Replace the namespace + $contents = file_get_contents($file); + $contents = str_replace('namespace Math\\Auth', 'namespace ' . APP_NAMESPACE, ); + file_put_contents($file, $contents); + } + } + } + +Now when your module users run ``php spark auth:publish`` they will have the following added to their project:: + + app/Controllers/AuthController.php + app/Database/Migrations/2017-11-20-223112_create_auth_tables.php.php + app/Models/LoginModel.php + app/Models/UserModel.php + +***************** +Library Reference +***************** + +Support Methods +=============== + +**[static] discover(string $directory = 'Publishers'): Publisher[]** + +Discovers and returns all Publishers in the specified namespace directory. For example, if both +**app/Publishers/FrameworkPublisher.php** and **myModule/src/Publishers/AssetPublisher.php** exist and are +extensions of ``Publisher`` then ``Publisher::discover()`` would return an instance of each. + +**publish(): bool** + +Processes the full input-process-output chain. By default this is the equivalent of calling ``addPath($source)`` +and ``merge(true)`` but child classes will typically provide their own implementation. ``publish()`` is called +on all discovered Publishers when running ``spark publish``. +Returns success or failure. + +**getScratch(): string** + +Returns the temporary workspace, creating it if necessary. Some operations use intermediate storage to stage +files and changes, and this provides the path to a transient, writable directory that you may use as well. + +**getErrors(): array** + +Returns any errors from the last write operation. The array keys are the files that caused the error, and the +values are the Throwable that was caught. Use ``getMessage()`` on the Throwable to get the error message. + +**getFiles(): string[]** + +Returns an array of all the loaded input files. + +Inputting Files +=============== + +**setFiles(array $files)** + +Sets the list of input files to the provided string array of file paths. + +**addFile(string $file)** +**addFiles(array $files)** + +Adds the file or files to the current list of input files. Files are absolute paths to actual files. + +**removeFile(string $file)** +**removeFiles(array $files)** + +Removes the file or files from the current list of input files. + +**addDirectory(string $directory, bool $recursive = false)** +**addDirectories(array $directories, bool $recursive = false)** + +Adds all files from the directory or directories, optionally recursing into sub-directories. Directories are +absolute paths to actual directories. + +**addPath(string $path, bool $recursive = true)** +**addPaths(array $path, bool $recursive = true)** + +Adds all files indicated by the relative paths. Paths are references to actual files or directories relative +to ``$source``. If the relative path resolves to a directory then ``$recursive`` will include sub-directories. + +**addUri(string $uri)** +**addUris(array $uris)** + +Downloads the contents of a URI using ``CURLRequest`` into the scratch workspace then adds the resulting +file to the list. + +.. note:: The CURL request made is a simple ``GET`` and uses the response body for the file contents. Some + remote files may need a custom request to be handled properly. + +Filtering Files +=============== + +**removePattern(string $pattern, string $scope = null)** +**retainPattern(string $pattern, string $scope = null)** + +Filters the current file list through the pattern (and optional scope), removing or retaining matched +files. ``$pattern`` may be a complete regex (like ``'#[A-Za-z]+\.php#'``) or a pseudo-regex similar +to ``glob()`` (like ``*.css``). +If a ``$scope`` is provided then only files in or under that directory will be considered (i.e. files +outside of ``$scope`` are always retained). When no scope is provided then all files are subject. + +Examples:: + + $publisher = new Publisher(APPPATH . 'Config'); + $publisher->addPath('/', true); // Adds all Config files and directories + + $publisher->removePattern('*tion.php'); // Would remove Encryption.php, Validation.php, and boot/production.php + $publisher->removePattern('*tion.php', APPPATH . 'Config/boot'); // Would only remove boot/production.php + + $publisher->retainPattern('#A.+php$#'); // Would keep only Autoload.php + $publisher->retainPattern('#d.+php$#', APPPATH . 'Config/boot'); // Would keep everything but boot/production.php and boot/testing.php + +Outputting Files +================ + +**wipe()** + +Removes all files, directories, and sub-directories from ``$destination``. + +.. important:: Use wisely. + +**copy(bool $replace = true): bool** + +Copies all files into the ``$destination``. This does not recreate the directory structure, so every file +from the current list will end up in the same destination directory. Using ``$replace`` will cause files +to overwrite when there is already an existing file. Returns success or failure, use ``getPublished()`` +and ``getErrors()`` to troubleshoot failures. +Be mindful of duplicate basename collisions, for example:: + + $publisher = new Publisher('/home/source', '/home/destination'); + $publisher->addPaths([ + 'pencil/lead.png', + 'metal/lead.png', + ]); + + // This is bad! Only one file will remain at /home/destination/lead.png + $publisher->copy(true); + +**merge(bool $replace = true): bool** + +Copies all files into the ``$destination`` in appropriate relative sub-directories. Any files that +match ``$source`` will be placed into their equivalent directories in ``$destination``, effectively +creating a "mirror" or "rsync" operation. Using ``$replace`` will cause files +to overwrite when there is already an existing file; since directories are merged this will not +affect other files in the destination. Returns success or failure, use ``getPublished()`` and +``getErrors()`` to troubleshoot failures. + +Example:: + + $publisher = new Publisher('/home/source', '/home/destination'); + $publisher->addPaths([ + 'pencil/lead.png', + 'metal/lead.png', + ]); + + // Results in "/home/destination/pencil/lead.png" and "/home/destination/metal/lead.png" + $publisher->merge(); From ad412189411ce195b972be82422570667a0ed066 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sat, 29 May 2021 15:18:50 +0000 Subject: [PATCH 07/65] Add Publish command --- system/Commands/Utilities/Publish.php | 114 ++++++++++++++++++ system/Language/en/Publisher.php | 5 + system/Publisher/Publisher.php | 1 + tests/_support/Publishers/TestPublisher.php | 29 ++--- tests/system/Commands/PublishCommandTest.php | 52 ++++++++ user_guide_src/source/libraries/publisher.rst | 7 +- 6 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 system/Commands/Utilities/Publish.php create mode 100644 tests/system/Commands/PublishCommandTest.php diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php new file mode 100644 index 000000000000..b9665286f348 --- /dev/null +++ b/system/Commands/Utilities/Publish.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Publisher\Publisher; + +/** + * Discovers all Publisher classes from the "Publishers/" directory + * across namespaces. Executes `publish()` from each instance, parsing + * each result. + */ +class Publish extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'publish'; + + /** + * The Command's short description + * + * @var string + */ + protected $description = 'Discovers and executes all predefined Publisher classes.'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'publish [directory]'; + + /** + * The Command's arguments + * + * @var array + */ + protected $arguments = [ + 'directory' => '[Optional] The directory to scan within each namespace. Default: "Publishers".', + ]; + + /** + * the Command's Options + * + * @var array + */ + protected $options = []; + + //-------------------------------------------------------------------- + + /** + * Displays the help for the spark cli script itself. + * + * @param array $params + */ + public function run(array $params) + { + $directory = array_shift($params) ?? 'Publishers'; + + if ([] === $publishers = Publisher::discover($directory)) + { + CLI::write(lang('Publisher.publishMissing', [$directory])); + return; + } + + foreach ($publishers as $publisher) + { + if ($publisher->publish()) + { + CLI::write(lang('Publisher.publishSuccess', [ + get_class($publisher), + count($publisher->getPublished()), + $publisher->getDestination(), + ]), 'green'); + } + else + { + CLI::error(lang('Publisher.publishFailure', [ + get_class($publisher), + $publisher->getDestination(), + ]), 'light_gray', 'red'); + + foreach ($publisher->getErrors() as $file => $exception) + { + CLI::write($file); + CLI::newLine(); + + $this->showError($exception); + } + } + } + } +} diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php index cb5ce81f10a4..934342b4f98c 100644 --- a/system/Language/en/Publisher.php +++ b/system/Language/en/Publisher.php @@ -14,4 +14,9 @@ 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', 'expectedFile' => 'Publisher::{0} expects a valid file.', + + // Publish Command + 'publishMissing' => 'No Publisher classes detected in {0} across all namespaces.', + 'publishSuccess' => '{0} published {1} file(s) to {2}.', + 'publishFailure' => '{0} failed to publish to {1}!', ]; diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index b9e5cca24e90..7018df15c7d1 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -317,6 +317,7 @@ public function __destruct() */ public function publish(): bool { + // Safeguard against accidental misuse if ($this->source === ROOTPATH && $this->destination === FCPATH) { throw new RuntimeException('Child classes of Publisher should provide their own source and destination or publish method.'); diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index 9192c3899b83..cf1eefa7ede6 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -3,6 +3,7 @@ namespace Tests\Support\Publishers; use CodeIgniter\Publisher\Publisher; +use RuntimeException; final class TestPublisher extends Publisher { @@ -11,17 +12,17 @@ final class TestPublisher extends Publisher * * @return $this */ - public static function setError(string $file) + public static function setResult(bool $result) { - self::$error = $file; + self::$result = $result; } /** - * A file to cause an error + * Return value for publish() * - * @var string + * @var boolean */ - private static $error = ''; + private static $result = true; /** * Base path to use for the source. @@ -42,24 +43,8 @@ public static function setError(string $file) */ public function publish(): bool { - $this->errors = $this->published = []; - $this->addPath(''); - // Copy each sourced file to its relative destination - foreach ($this->getFiles() as $file) - { - if ($file === self::$error) - { - $this->errors[$file] = new RuntimeException('Have an error, dear.'); - } - else - { - // Resolve the destination path - $this->published[] = $this->destination . substr($file, strlen($this->source)); - } - } - - return $this->errors === []; + return self::$result; } } diff --git a/tests/system/Commands/PublishCommandTest.php b/tests/system/Commands/PublishCommandTest.php new file mode 100644 index 000000000000..44f2de25f85a --- /dev/null +++ b/tests/system/Commands/PublishCommandTest.php @@ -0,0 +1,52 @@ +streamFilter = stream_filter_append(STDOUT, 'CITestStreamFilter'); + $this->streamFilter = stream_filter_append(STDERR, 'CITestStreamFilter'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + stream_filter_remove($this->streamFilter); + TestPublisher::setResult(true); + } + + public function testDefault() + { + command('publish'); + + $this->assertStringContainsString(lang('Publisher.publishSuccess', [ + TestPublisher::class, + 0, + WRITEPATH, + ]), CITestStreamFilter::$buffer); + } + + public function testFailure() + { + TestPublisher::setResult(false); + + command('publish'); + + $this->assertStringContainsString(lang('Publisher.publishFailure', [ + TestPublisher::class, + WRITEPATH, + ]), CITestStreamFilter::$buffer); + } +} diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index ef97e0b52f95..23f7c2d9e97e 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -246,7 +246,7 @@ into an application for use:: use CodeIgniter\Publisher\Publisher; use Throwable; - class Publish extends BaseCommand + class AuthPublish extends BaseCommand { protected $group = 'Auth'; protected $name = 'auth:publish'; @@ -275,11 +275,8 @@ into an application for use:: } // If publication succeeded then update namespaces - foreach ($publisher->getFiles as $original) + foreach ($publisher->getPublished() as $file) { - // Get the location of the new file - $file = str_replace($source, APPPATH, $original); - // Replace the namespace $contents = file_get_contents($file); $contents = str_replace('namespace Math\\Auth', 'namespace ' . APP_NAMESPACE, ); From c974add8ac05486a890bfffc7be5ebbb4f293a66 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sat, 29 May 2021 15:43:14 +0000 Subject: [PATCH 08/65] Fix test bleeding --- tests/system/Publisher/PublisherSupportTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index f17be3460432..2aff0e34d1f6 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -53,7 +53,7 @@ public function testDiscoverNothing() public function testDiscoverStores() { $publisher = Publisher::discover()[0]; - $publisher->addFile($this->file); + $publisher->setFiles([])->addFile($this->file); $result = Publisher::discover(); $this->assertSame($publisher, $result[0]); From 5906310002da9f935a5991f094f1aee0e7356830 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sat, 29 May 2021 20:17:28 +0000 Subject: [PATCH 09/65] Fix UG format --- user_guide_src/source/libraries/publisher.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index 23f7c2d9e97e..c30bce720815 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -119,6 +119,8 @@ You want to display a "photo of the day" image on your homepage. You have a feed need to get the actual file into a browsable location in your project at **public/images/daily_photo.jpg**. You can set up :doc:`Custom Command ` to run daily that will handle this for you:: + composer require twbs/bootstrap > php spark publish -... and you'll end up with something like this: +... and you'll end up with something like this:: public/.htaccess public/favicon.ico @@ -240,6 +244,8 @@ You want to allow developers using your popular authentication module the abilit of your Migration, Controller, and Model. You can create your own module "publish" command to inject these components into an application for use:: + Date: Tue, 1 Jun 2021 14:44:25 +0000 Subject: [PATCH 10/65] Implement review changes --- system/Commands/Utilities/Publish.php | 5 ++--- .../Exceptions/PublisherException.php | 21 +++++++++++++++++++ tests/_support/Publishers/TestPublisher.php | 2 -- user_guide_src/source/libraries/index.rst | 1 + user_guide_src/source/libraries/publisher.rst | 18 +++++++++------- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php index b9665286f348..15448fc90db3 100644 --- a/system/Commands/Utilities/Publish.php +++ b/system/Commands/Utilities/Publish.php @@ -49,7 +49,7 @@ class Publish extends BaseCommand * * @var string */ - protected $usage = 'publish [directory]'; + protected $usage = 'publish []'; /** * The Command's arguments @@ -104,9 +104,8 @@ public function run(array $params) foreach ($publisher->getErrors() as $file => $exception) { CLI::write($file); + CLI::error($exception->getMessage()); CLI::newLine(); - - $this->showError($exception); } } } diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php index 81f9bb4b7174..8144fcfeac02 100644 --- a/system/Publisher/Exceptions/PublisherException.php +++ b/system/Publisher/Exceptions/PublisherException.php @@ -13,18 +13,39 @@ use CodeIgniter\Exceptions\FrameworkException; +/** + * Publisher Exception Class + * + * Handles exceptions related to actions taken by a Publisher. + */ class PublisherException extends FrameworkException { + /** + * Throws when a file should be overwritten yet cannot. + * + * @param string $from The source file + * @param string $to The destination file + */ public static function forCollision(string $from, string $to) { return new static(lang('Publisher.collision', [filetype($to), $from, $to])); } + /** + * Throws when an object is expected to be a directory but is not or is missing. + * + * @param string $caller The method causing the exception + */ public static function forExpectedDirectory(string $caller) { return new static(lang('Publisher.expectedDirectory', [$caller])); } + /** + * Throws when an object is expected to be a file but is not or is missing. + * + * @param string $caller The method causing the exception + */ public static function forExpectedFile(string $caller) { return new static(lang('Publisher.expectedFile', [$caller])); diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index cf1eefa7ede6..47c987a0285a 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -9,8 +9,6 @@ final class TestPublisher extends Publisher { /** * Fakes an error on the given file. - * - * @return $this */ public static function setResult(bool $result) { diff --git a/user_guide_src/source/libraries/index.rst b/user_guide_src/source/libraries/index.rst index 3af27e900ce5..9f977e91d604 100644 --- a/user_guide_src/source/libraries/index.rst +++ b/user_guide_src/source/libraries/index.rst @@ -14,6 +14,7 @@ Library Reference honeypot images pagination + publisher security sessions throttler diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index c30bce720815..57abcfcd6925 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -44,11 +44,12 @@ By default the source and destination will be set to ``ROOTPATH`` and ``FCPATH`` easy access to take any file from your project and make it web-accessible. Alternatively you may pass a new source or source and destination into the constructor:: + use CodeIgniter\Publisher\Publisher; + $vendorPublisher = new Publisher(ROOTPATH . 'vendor'); $filterPublisher = new Publisher('/path/to/module/Filters', APPPATH . 'Filters'); -Once the source and destination are set you may start adding relative input files:: - + // Once the source and destination are set you may start adding relative input files $frameworkPublisher = new Publisher(ROOTPATH . 'vendor/codeigniter4/codeigniter4'); // All "path" commands are relative to $source @@ -73,7 +74,7 @@ to their destination(s):: // Place files into their relative $destination directories, overwriting and saving the boolean result $result = $frameworkPublisher->merge(true); -See the Library Reference for a full description of available methods. +See the :ref:`reference` for a full description of available methods. Automation and Discovery ======================== @@ -90,7 +91,7 @@ the powerful ``Autoloader`` to locate any child classes primed for publication:: if ($result === false) { - CLI::write(get_class($publisher) . ' failed to publish!', 'red'); + CLI::error(get_class($publisher) . ' failed to publish!', 'red'); } } @@ -139,7 +140,7 @@ You can set up :doc:`Custom Command ` to run daily that will try { - $publisher->addPath('daily_photo.jpg')->copy($replace = true); + $publisher->addPath('daily_photo.jpg')->copy(true); // `true` to enable overwrites } catch (Throwable $e) { @@ -152,7 +153,7 @@ Now running ``spark publish:daily`` will keep your homepage's image up-to-date. coming from an external API? You can use ``addUri()`` in place of ``addPath()`` to download the remote resource and publish it out instead:: - $publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy($replace = true); + $publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy(true); Asset Dependencies Example ========================== @@ -161,8 +162,6 @@ You want to integrate the frontend library "Bootstrap" into your project, but th to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending ``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: - merge(true); } + } Now add the dependency via Composer and call ``spark publish`` to run the publication:: @@ -298,6 +298,8 @@ Now when your module users run ``php spark auth:publish`` they will have the fol app/Models/LoginModel.php app/Models/UserModel.php +.. _reference: + ***************** Library Reference ***************** From fb7080790973643a871ede1562eeaacfa21647c8 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 1 Jun 2021 17:23:22 +0000 Subject: [PATCH 11/65] Tweak formatting --- user_guide_src/source/libraries/publisher.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index 57abcfcd6925..1cb27b54aa2b 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -162,6 +162,8 @@ You want to integrate the frontend library "Bootstrap" into your project, but th to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending ``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: + addPath('dist') // Indicate we only want the minimized versions - ->retainPattern('*.min.*) + ->retainPattern('*.min.*') // Merge-and-replace to retain the original directory structure ->merge(true); From f33d8809fa96de9fcdcd30042fc4435410abb799 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 8 Jun 2021 16:22:12 +0000 Subject: [PATCH 12/65] Add Publisher restrictions --- app/Config/Publisher.php | 28 ++++ system/Config/Publisher.php | 42 ++++++ system/Language/en/Publisher.php | 8 +- .../Exceptions/PublisherException.php | 22 +++ system/Publisher/Publisher.php | 131 +++++++++++------- tests/_support/Config/Registrar.php | 14 ++ tests/system/Publisher/PublisherInputTest.php | 2 +- .../system/Publisher/PublisherOutputTest.php | 5 +- .../Publisher/PublisherRestrictionsTest.php | 108 +++++++++++++++ .../system/Publisher/PublisherSupportTest.php | 3 +- user_guide_src/source/libraries/publisher.rst | 16 ++- 11 files changed, 325 insertions(+), 54 deletions(-) create mode 100644 app/Config/Publisher.php create mode 100644 system/Config/Publisher.php create mode 100644 tests/system/Publisher/PublisherRestrictionsTest.php diff --git a/app/Config/Publisher.php b/app/Config/Publisher.php new file mode 100644 index 000000000000..2588eea2abac --- /dev/null +++ b/app/Config/Publisher.php @@ -0,0 +1,28 @@ + + */ + public $restrictions = [ + ROOTPATH => '*', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; +} diff --git a/system/Config/Publisher.php b/system/Config/Publisher.php new file mode 100644 index 000000000000..651600ef9c06 --- /dev/null +++ b/system/Config/Publisher.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Publisher Configuration + * + * Defines basic security restrictions for the Publisher class + * to prevent abuse by injecting malicious files into a project. + */ +class Publisher extends BaseConfig +{ + /** + * A list of allowed destinations with a (pseudo-)regex + * of allowed files for each destination. + * Attempts to publish to directories not in this list will + * result in a PublisherException. Files that do no fit the + * pattern will cause copy/merge to fail. + * + * @var array + */ + public $restrictions = [ + ROOTPATH => '*', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; + + /** + * Disables Registrars to prevent modules from altering the restrictions. + */ + final protected function registerProperties() + { + } +} diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php index 934342b4f98c..2d7ae8418b25 100644 --- a/system/Language/en/Publisher.php +++ b/system/Language/en/Publisher.php @@ -11,9 +11,11 @@ // Publisher language settings return [ - 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', - 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', - 'expectedFile' => 'Publisher::{0} expects a valid file.', + 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', + 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', + 'expectedFile' => 'Publisher::{0} expects a valid file.', + 'destinationNotAllowed' => 'Destination is not on the allowed list of Publisher directories: {0}', + 'fileNotAllowed' => '{0} fails the following restriction for {1}: {2}', // Publish Command 'publishMissing' => 'No Publisher classes detected in {0} across all namespaces.', diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php index 8144fcfeac02..d54420881ec7 100644 --- a/system/Publisher/Exceptions/PublisherException.php +++ b/system/Publisher/Exceptions/PublisherException.php @@ -50,4 +50,26 @@ public static function forExpectedFile(string $caller) { return new static(lang('Publisher.expectedFile', [$caller])); } + + /** + * Throws when given a destination that is not in the list of allowed directories. + * + * @param string $destination + */ + public static function forDestinationNotAllowed(string $destination) + { + return new static(lang('Publisher.destinationNotAllowed', [$destination])); + } + + /** + * Throws when a file fails to match the allowed pattern for its destination. + * + * @param string $file + * @param string $directory + * @param string $pattern + */ + public static function forFileNotAllowed(string $file, string $directory, string $pattern) + { + return new static(lang('Publisher.fileNotAllowed', [$file, $directory, $pattern])); + } } diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 7018df15c7d1..1bfd4b91768d 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -72,6 +72,14 @@ class Publisher */ private $published = []; + /** + * List of allowed directories and their allowed files regex. + * Restrictions are intentionally private to prevent overriding. + * + * @var array + */ + private $restrictions; + /** * Base path to use for the source. * @@ -134,6 +142,8 @@ final public static function discover(string $directory = 'Publishers'): array * @param string $directory * * @return string + * + * @throws PublisherException */ private static function resolveDirectory(string $directory): string { @@ -152,6 +162,8 @@ private static function resolveDirectory(string $directory): string * @param string $file * * @return string + * + * @throws PublisherException */ private static function resolveFile(string $file): string { @@ -191,7 +203,7 @@ private static function filterFiles(array $files, string $directory): array * * @return string[] */ - private static function matchFiles(array $files, string $pattern) + private static function matchFiles(array $files, string $pattern): array { // Convert pseudo-regex into their true form if (@preg_match($pattern, null) === false) // @phpstan-ignore-line @@ -236,49 +248,6 @@ private static function wipeDirectory(string $directory): void } } - /** - * Copies a file with directory creation and identical file awareness. - * Intentionally allows errors. - * - * @param string $from - * @param string $to - * @param boolean $replace - * - * @return void - * - * @throws PublisherException For unresolvable collisions - */ - private static function safeCopyFile(string $from, string $to, bool $replace): void - { - // Check for an existing file - if (file_exists($to)) - { - // If not replacing or if files are identical then consider successful - if (! $replace || same_file($from, $to)) - { - return; - } - - // If it is a directory then do not try to remove it - if (is_dir($to)) - { - throw PublisherException::forCollision($from, $to); - } - - // Try to remove anything else - unlink($to); - } - - // Make sure the directory exists - if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) - { - mkdir($directory, 0775, true); - } - - // Allow copy() to throw errors - copy($from, $to); - } - //-------------------------------------------------------------------- /** @@ -293,6 +262,20 @@ public function __construct(string $source = null, string $destination = null) $this->source = self::resolveDirectory($source ?? $this->source); $this->destination = self::resolveDirectory($destination ?? $this->destination); + + // Restrictions are intentionally not injected to prevent overriding + $this->restrictions = config('Publisher')->restrictions; + + // Make sure the destination is allowed + foreach (array_keys($this->restrictions) as $directory) + { + if (strpos($this->destination, $directory) === 0) + { + return; + } + } + + throw PublisherException::forDestinationNotAllowed($this->destination); } /** @@ -314,6 +297,8 @@ public function __destruct() * discovery. * * @return boolean + * + * @throws RuntimeException */ public function publish(): bool { @@ -683,7 +668,7 @@ final public function copy(bool $replace = true): bool try { - self::safeCopyFile($file, $to, $replace); + $this->safeCopyFile($file, $to, $replace); $this->published[] = $to; } catch (Throwable $e) @@ -707,7 +692,7 @@ final public function merge(bool $replace = true): bool { $this->errors = $this->published = []; - // Get the file from source for special handling + // Get the files from source for special handling $sourced = self::filterFiles($this->getFiles(), $this->source); // Handle everything else with a flat copy @@ -722,7 +707,7 @@ final public function merge(bool $replace = true): bool try { - self::safeCopyFile($file, $to, $replace); + $this->safeCopyFile($file, $to, $replace); $this->published[] = $to; } catch (Throwable $e) @@ -733,4 +718,56 @@ final public function merge(bool $replace = true): bool return $this->errors === []; } + + /** + * Copies a file with directory creation and identical file awareness. + * Intentionally allows errors. + * + * @param string $from + * @param string $to + * @param boolean $replace + * + * @return void + * + * @throws PublisherException For collisions and restriction violations + */ + private function safeCopyFile(string $from, string $to, bool $replace): void + { + // Verify this is an allowed file for its destination + foreach ($this->restrictions as $directory => $pattern) + { + if (strpos($to, $directory) === 0 && self::matchFiles([$to], $pattern) === []) + { + throw PublisherException::forFileNotAllowed($from, $directory, $pattern); + } + } + + // Check for an existing file + if (file_exists($to)) + { + // If not replacing or if files are identical then consider successful + if (! $replace || same_file($from, $to)) + { + return; + } + + // If it is a directory then do not try to remove it + if (is_dir($to)) + { + throw PublisherException::forCollision($from, $to); + } + + // Try to remove anything else + unlink($to); + } + + // Make sure the directory exists + if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) + { + mkdir($directory, 0775, true); + } + + // Allow copy() to throw errors + copy($from, $to); + } } diff --git a/tests/_support/Config/Registrar.php b/tests/_support/Config/Registrar.php index 4135079b73e2..d80522ba615f 100644 --- a/tests/_support/Config/Registrar.php +++ b/tests/_support/Config/Registrar.php @@ -113,4 +113,18 @@ public static function Database() return $config; } + + /** + * Demonstrates Publisher security. + * + * @see PublisherRestrictionsTest::testRegistrarsNotAllowed() + * + * @return array + */ + public static function Publisher() + { + return [ + 'restrictions' => [SUPPORTPATH => '*'], + ]; + } } diff --git a/tests/system/Publisher/PublisherInputTest.php b/tests/system/Publisher/PublisherInputTest.php index 553a92b14c53..e7450f5c02a9 100644 --- a/tests/system/Publisher/PublisherInputTest.php +++ b/tests/system/Publisher/PublisherInputTest.php @@ -1,4 +1,4 @@ -root = vfsStream::setup('root', null, $this->structure); + + // Add root to the list of allowed destinations + config('Publisher')->restrictions[$this->root->url()] = '*'; } //-------------------------------------------------------------------- diff --git a/tests/system/Publisher/PublisherRestrictionsTest.php b/tests/system/Publisher/PublisherRestrictionsTest.php new file mode 100644 index 000000000000..27db2e9429a9 --- /dev/null +++ b/tests/system/Publisher/PublisherRestrictionsTest.php @@ -0,0 +1,108 @@ +assertArrayNotHasKey(SUPPORTPATH, config('Publisher')->restrictions); + } + + public function testImmutableRestrictions() + { + $publisher = new Publisher(); + + // Try to "hack" the Publisher by adding our desired destination to the config + config('Publisher')->restrictions[SUPPORTPATH] = '*'; + + $restrictions = $this->getPrivateProperty($publisher, 'restrictions'); + + $this->assertArrayNotHasKey(SUPPORTPATH, $restrictions); + } + + /** + * @dataProvider fileProvider + */ + public function testDefaultPublicRestrictions(string $path) + { + $publisher = new Publisher(ROOTPATH, FCPATH); + $pattern = config('Publisher')->restrictions[FCPATH]; + + // Use the scratch space to create a file + $file = $publisher->getScratch() . $path; + file_put_contents($file, 'To infinity and beyond!'); + + $result = $publisher->addFile($file)->merge(); + $this->assertFalse($result); + + $errors = $publisher->getErrors(); + $this->assertCount(1, $errors); + $this->assertSame([$file], array_keys($errors)); + + $expected = lang('Publisher.fileNotAllowed', [$file, FCPATH, $pattern]); + $this->assertSame($expected, $errors[$file]->getMessage()); + } + + public function fileProvider() + { + yield 'php' => ['index.php']; + yield 'exe' => ['cat.exe']; + yield 'flat' => ['banana']; + } + + /** + * @dataProvider destinationProvider + */ + public function testDestinations(string $destination, bool $allowed) + { + config('Publisher')->restrictions = [ + APPPATH => '', + FCPATH => '', + SUPPORTPATH . 'Files' => '', + SUPPORTPATH . 'Files/../' => '', + ]; + + if (! $allowed) + { + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.destinationNotAllowed', [$destination])); + } + + $publisher = new Publisher(null, $destination); + $this->assertInstanceOf(Publisher::class, $publisher); + } + + public function destinationProvider() + { + return [ + 'explicit' => [ + APPPATH, + true, + ], + 'subdirectory' => [ + APPPATH . 'Config', + true, + ], + 'relative' => [ + SUPPORTPATH . 'Files/able/../', + true, + ], + 'parent' => [ + SUPPORTPATH, + false, + ], + ]; + } +} diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index 2aff0e34d1f6..42f22d721525 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -1,4 +1,4 @@ -assertDirectoryExists($directory); + config('Publisher')->restrictions[$directory] = ''; // Allow the directory $publisher = new Publisher($this->directory, $directory); $publisher->wipe(); diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index 1cb27b54aa2b..84b430ba144b 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -107,6 +107,20 @@ Most of the time you will not need to handle your own discovery, just use the pr By default on your class extension ``publish()`` will add all files from your ``$source`` and merge them out to your destination, overwriting on collision. +Security +======== + +In order to prevent modules from injecting malicious code into your projects, ``Publisher`` contains a config file +that defines which directories and file patterns are allowed as destinations. By default, files may only be published +to your project (to prevent access to the rest of the filesystem), and the **public/** folder (``FCPATH``) will only +receive files with the following extensions: +* Web assets: css, scss, js, map +* Non-executable web files: htm, html, xml, json, webmanifest +* Fonts: tff, eot, woff +* Images: gif, jpg, jpeg, tiff, png, webp, bmp, ico, svg + +If you need to add or adjust the security for your project then alter the ``$restrictions`` property of ``Config\Publisher``. + ******** Examples ******** @@ -159,7 +173,7 @@ Asset Dependencies Example ========================== You want to integrate the frontend library "Bootstrap" into your project, but the frequent updates makes it a hassle -to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending +to keep up with. You can create a publication definition in your project to sync frontend assets by extending ``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: Date: Sat, 12 Jun 2021 13:25:40 +0200 Subject: [PATCH 13/65] Graphic fix on some screen res Before: https://prnt.sc/1559re2 After: https://prnt.sc/1559uou --- app/Views/welcome_message.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php index 7050aa91174a..9ee2e427c308 100644 --- a/app/Views/welcome_message.php +++ b/app/Views/welcome_message.php @@ -163,7 +163,7 @@ color: rgba(200, 200, 200, 1); padding: .25rem 1.75rem; } - @media (max-width: 559px) { + @media (max-width: 629px) { header ul { padding: 0; } From 5a6f664a29a929fb8ea991fd118ba69664422cbb Mon Sep 17 00:00:00 2001 From: MGatner Date: Sun, 13 Jun 2021 23:05:43 +0000 Subject: [PATCH 14/65] Implement FileCollection --- system/Files/Exceptions/FileException.php | 20 + system/Files/FileCollection.php | 386 +++++++++++++ system/Language/en/Files.php | 6 +- system/Language/en/Publisher.php | 2 - .../Exceptions/PublisherException.php | 20 - system/Publisher/Publisher.php | 308 +--------- tests/system/Files/FileCollectionTest.php | 536 ++++++++++++++++++ tests/system/Publisher/PublisherInputTest.php | 338 +---------- .../system/Publisher/PublisherSupportTest.php | 68 +-- user_guide_src/source/libraries/files.rst | 96 ++++ user_guide_src/source/libraries/publisher.rst | 56 +- 11 files changed, 1066 insertions(+), 770 deletions(-) create mode 100644 system/Files/FileCollection.php create mode 100644 tests/system/Files/FileCollectionTest.php diff --git a/system/Files/Exceptions/FileException.php b/system/Files/Exceptions/FileException.php index ddaac3639b72..ffcfb61c0bbc 100644 --- a/system/Files/Exceptions/FileException.php +++ b/system/Files/Exceptions/FileException.php @@ -23,4 +23,24 @@ public static function forUnableToMove(string $from = null, string $to = null, s { return new static(lang('Files.cannotMove', [$from, $to, $error])); } + + /** + * Throws when an item is expected to be a directory but is not or is missing. + * + * @param string $caller The method causing the exception + */ + public static function forExpectedDirectory(string $caller) + { + return new static(lang('Files.expectedDirectory', [$caller])); + } + + /** + * Throws when an item is expected to be a file but is not or is missing. + * + * @param string $caller The method causing the exception + */ + public static function forExpectedFile(string $caller) + { + return new static(lang('Files.expectedFile', [$caller])); + } } diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php new file mode 100644 index 000000000000..bf4eeaf6b836 --- /dev/null +++ b/system/Files/FileCollection.php @@ -0,0 +1,386 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Files; + +use CodeIgniter\Files\Exceptions\FileException; +use CodeIgniter\Files\Exceptions\FileNotFoundException; +use Countable; +use Generator; +use InvalidArgumentException; +use IteratorAggregate; +use Traversable; + +/** + * File Collection Class + * + * Representation for a group of files, with utilities for locating, + * filtering, and ordering them. + */ +class FileCollection implements Countable, IteratorAggregate +{ + /** + * The current list of file paths. + * + * @var string[] + */ + protected $files = []; + + //-------------------------------------------------------------------- + + /** + * Resolves a full path and verifies it is an actual directory. + * + * @param string $directory + * + * @return string + * + * @throws FileException + */ + protected static function resolveDirectory(string $directory): string + { + if (! is_dir($directory = set_realpath($directory))) + { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + throw FileException::forExpectedDirectory($caller['function']); + } + + return $directory; + } + + /** + * Resolves a full path and verifies it is an actual file. + * + * @param string $file + * + * @return string + * + * @throws FileException + */ + protected static function resolveFile(string $file): string + { + if (! is_file($file = set_realpath($file))) + { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + throw FileException::forExpectedFile($caller['function']); + } + + return $file; + } + + /** + * Removes files that are not part of the given directory (recursive). + * + * @param string[] $files + * @param string $directory + * + * @return string[] + */ + protected static function filterFiles(array $files, string $directory): array + { + $directory = self::resolveDirectory($directory); + + return array_filter($files, function ($value) use ($directory) { + return strpos($value, $directory) === 0; + }); + } + + /** + * Returns any files whose `basename` matches the given pattern. + * + * @param string[] $files + * @param string $pattern Regex or pseudo-regex string + * + * @return string[] + */ + protected static function matchFiles(array $files, string $pattern): array + { + // Convert pseudo-regex into their true form + if (@preg_match($pattern, null) === false) // @phpstan-ignore-line + { + $pattern = str_replace( + ['#', '.', '*', '?'], + ['\#', '\.', '.*', '.'], + $pattern + ); + $pattern = "#{$pattern}#"; + } + + return array_filter($files, function ($value) use ($pattern) { + return (bool) preg_match($pattern, basename($value)); + }); + } + + //-------------------------------------------------------------------- + + /** + * Loads the Filesystem helper and stores initial files. + * + * @param string[] $files + */ + public function __construct(array $files = []) + { + helper(['filesystem']); + + $this->set($files); + } + + /** + * Optimizes and returns the current file list. + * + * @return string[] + */ + public function get(): array + { + $this->files = array_unique($this->files, SORT_STRING); + sort($this->files, SORT_STRING); + + return $this->files; + } + + /** + * Sets the file list directly, files are still subject to verification. + * This works as a "reset" method with []. + * + * @param string[] $files The new file list to use + * + * @return $this + */ + public function set(array $files) + { + $this->files = []; + + return $this->addFiles($files); + } + + /** + * Adds an array/single file or directory to the list. + * + * @param string|string[] $paths + * @param boolean $recursive + * + * @return $this + */ + public function add($paths, bool $recursive = true) + { + if (! is_array($paths)) + { + $paths = [$paths]; + } + + foreach ($paths as $path) + { + if (! is_string($path)) + { + throw new InvalidArgumentException('FileCollection paths must be strings.'); + } + + // Test for a directory + try + { + $directory = self::resolveDirectory($path); + } + catch (FileException $e) + { + return $this->addFile($path); + } + + $this->addDirectory($path, $recursive); + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds files to the list. + * + * @param string[] $files + * + * @return $this + */ + public function addFiles(array $files) + { + foreach ($files as $file) + { + $this->addFile($file); + } + + return $this; + } + + /** + * Verifies and adds a single file to the file list. + * + * @param string $file + * + * @return $this + */ + public function addFile(string $file) + { + $this->files[] = self::resolveFile($file); + + return $this; + } + + /** + * Removes files from the list. + * + * @param string[] $files + * + * @return $this + */ + public function removeFiles(array $files) + { + $this->files = array_diff($this->files, $files); + + return $this; + } + + /** + * Removes a single file from the list. + * + * @param string $file + * + * @return $this + */ + public function removeFile(string $file) + { + return $this->removeFiles([$file]); + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds files from each + * directory to the list. + * + * @param string[] $directories + * @param bool $recursive + * + * @return $this + */ + public function addDirectories(array $directories, bool $recursive = false) + { + foreach ($directories as $directory) + { + $this->addDirectory($directory, $recursive); + } + + return $this; + } + + /** + * Verifies and adds all files from a directory. + * + * @param string $directory + * @param boolean $recursive + * + * @return $this + */ + public function addDirectory(string $directory, bool $recursive = false) + { + $directory = self::resolveDirectory($directory); + + // Map the directory to depth 2 to so directories become arrays + foreach (directory_map($directory, 2, true) as $key => $path) + { + if (is_string($path)) + { + $this->addFile($directory . $path); + } + elseif ($recursive && is_array($path)) + { + $this->addDirectory($directory . $key, true); + } + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Removes any files from the list that match the supplied pattern + * (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope The directory to limit the scope + * + * @return $this + */ + public function removePattern(string $pattern, string $scope = null) + { + if ($pattern === '') + { + return $this; + } + + // Start with all files or those in scope + $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); + + // Remove any files that match the pattern + return $this->removeFiles(self::matchFiles($files, $pattern)); + } + + /** + * Keeps only the files from the list that match + * (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope A directory to limit the scope + * + * @return $this + */ + public function retainPattern(string $pattern, string $scope = null) + { + if ($pattern === '') + { + return $this; + } + + // Start with all files or those in scope + $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); + + // Matches the pattern within the scoped files and remove their inverse. + return $this->removeFiles(array_diff($files, self::matchFiles($files, $pattern))); + } + + //-------------------------------------------------------------------- + + /** + * Returns the current number of files in the collection. + * Fulfills Countable. + * + * @return int + */ + public function count(): int + { + return count($this->files); + } + + /** + * Yields as an Iterator for the current files. + * Fulfills IteratorAggregate. + * + * @throws FileNotFoundException + * + * @return Generator + */ + public function getIterator(): Generator + { + foreach ($this->get() as $file) + { + yield new File($file, true); + } + } +} diff --git a/system/Language/en/Files.php b/system/Language/en/Files.php index ccb9ab306876..f50764898fe3 100644 --- a/system/Language/en/Files.php +++ b/system/Language/en/Files.php @@ -11,6 +11,8 @@ // Files language settings return [ - 'fileNotFound' => 'File not found: {0}', - 'cannotMove' => 'Could not move file {0} to {1} ({2}).', + 'fileNotFound' => 'File not found: {0}', + 'cannotMove' => 'Could not move file {0} to {1} ({2}).', + 'expectedDirectory' => '{0} expects a valid directory.', + 'expectedFile' => '{0} expects a valid file.', ]; diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php index 2d7ae8418b25..77d805a78ff2 100644 --- a/system/Language/en/Publisher.php +++ b/system/Language/en/Publisher.php @@ -12,8 +12,6 @@ // Publisher language settings return [ 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', - 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', - 'expectedFile' => 'Publisher::{0} expects a valid file.', 'destinationNotAllowed' => 'Destination is not on the allowed list of Publisher directories: {0}', 'fileNotAllowed' => '{0} fails the following restriction for {1}: {2}', diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php index d54420881ec7..c3e85a994ebe 100644 --- a/system/Publisher/Exceptions/PublisherException.php +++ b/system/Publisher/Exceptions/PublisherException.php @@ -31,26 +31,6 @@ public static function forCollision(string $from, string $to) return new static(lang('Publisher.collision', [filetype($to), $from, $to])); } - /** - * Throws when an object is expected to be a directory but is not or is missing. - * - * @param string $caller The method causing the exception - */ - public static function forExpectedDirectory(string $caller) - { - return new static(lang('Publisher.expectedDirectory', [$caller])); - } - - /** - * Throws when an object is expected to be a file but is not or is missing. - * - * @param string $caller The method causing the exception - */ - public static function forExpectedFile(string $caller) - { - return new static(lang('Publisher.expectedFile', [$caller])); - } - /** * Throws when given a destination that is not in the list of allowed directories. * diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 1bfd4b91768d..1f28bc331baf 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -12,7 +12,7 @@ namespace CodeIgniter\Publisher; use CodeIgniter\Autoloader\FileLocator; -use CodeIgniter\Files\File; +use CodeIgniter\Files\FileCollection; use CodeIgniter\HTTP\URI; use CodeIgniter\Publisher\Exceptions\PublisherException; use RuntimeException; @@ -28,13 +28,13 @@ * path to a verified file while a "path" is relative to its source * or destination and may indicate either a file or directory of * unconfirmed existence. - * class failures throw the PublisherException, but some underlying + * Class failures throw the PublisherException, but some underlying * methods may percolate different exceptions, like FileException, * FileNotFoundException or InvalidArgumentException. * Write operations will catch all errors in the file-specific * $errors property to minimize impact of partial batch operations. */ -class Publisher +class Publisher extends FileCollection { /** * Array of discovered Publishers. @@ -51,13 +51,6 @@ class Publisher */ private $scratch; - /** - * The current list of files. - * - * @var string[] - */ - private $files = []; - /** * Exceptions for specific files from the last write operation. * @@ -94,6 +87,8 @@ class Publisher */ protected $destination = FCPATH; + //-------------------------------------------------------------------- + /** * Discovers and returns all Publishers in the specified namespace directory. * @@ -134,95 +129,6 @@ final public static function discover(string $directory = 'Publishers'): array return self::$discovered[$directory]; } - //-------------------------------------------------------------------- - - /** - * Resolves a full path and verifies it is an actual directory. - * - * @param string $directory - * - * @return string - * - * @throws PublisherException - */ - private static function resolveDirectory(string $directory): string - { - if (! is_dir($directory = set_realpath($directory))) - { - $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; - throw PublisherException::forExpectedDirectory($caller['function']); - } - - return $directory; - } - - /** - * Resolves a full path and verifies it is an actual file. - * - * @param string $file - * - * @return string - * - * @throws PublisherException - */ - private static function resolveFile(string $file): string - { - if (! is_file($file = set_realpath($file))) - { - $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; - throw PublisherException::forExpectedFile($caller['function']); - } - - return $file; - } - - //-------------------------------------------------------------------- - - /** - * Removes files that are not part of the given directory (recursive). - * - * @param string[] $files - * @param string $directory - * - * @return string[] - */ - private static function filterFiles(array $files, string $directory): array - { - $directory = self::resolveDirectory($directory); - - return array_filter($files, function ($value) use ($directory) { - return strpos($value, $directory) === 0; - }); - } - - /** - * Returns any files whose `basename` matches the given pattern. - * - * @param array $files - * @param string $pattern Regex or pseudo-regex string - * - * @return string[] - */ - private static function matchFiles(array $files, string $pattern): array - { - // Convert pseudo-regex into their true form - if (@preg_match($pattern, null) === false) // @phpstan-ignore-line - { - $pattern = str_replace( - ['#', '.', '*', '?'], - ['\#', '\.', '.*', '.'], - $pattern - ); - $pattern = "#{$pattern}#"; - } - - return array_filter($files, function ($value) use ($pattern) { - return (bool) preg_match($pattern, basename($value)); - }); - } - - //-------------------------------------------------------------------- - /* * Removes a directory and all its files and subdirectories. * @@ -305,7 +211,7 @@ public function publish(): bool // Safeguard against accidental misuse if ($this->source === ROOTPATH && $this->destination === FCPATH) { - throw new RuntimeException('Child classes of Publisher should provide their own source and destination or publish method.'); + throw new RuntimeException('Child classes of Publisher should provide their own publish method or a source and destination.'); } return $this->addPath('/')->merge(true); @@ -369,142 +275,6 @@ final public function getPublished(): array return $this->published; } - /** - * Optimizes and returns the current file list. - * - * @return string[] - */ - final public function getFiles(): array - { - $this->files = array_unique($this->files, SORT_STRING); - sort($this->files, SORT_STRING); - - return $this->files; - } - - //-------------------------------------------------------------------- - - /** - * Sets the file list directly, files are still subject to verification. - * This works as a "reset" method with []. - * - * @param string[] $files The new file list to use - * - * @return $this - */ - final public function setFiles(array $files) - { - $this->files = []; - - return $this->addFiles($files); - } - - /** - * Verifies and adds files to the list. - * - * @param string[] $files - * - * @return $this - */ - final public function addFiles(array $files) - { - foreach ($files as $file) - { - $this->addFile($file); - } - - return $this; - } - - /** - * Verifies and adds a single file to the file list. - * - * @param string $file - * - * @return $this - */ - final public function addFile(string $file) - { - $this->files[] = self::resolveFile($file); - - return $this; - } - - /** - * Removes files from the list. - * - * @param string[] $files - * - * @return $this - */ - final public function removeFiles(array $files) - { - $this->files = array_diff($this->files, $files); - - return $this; - } - - /** - * Removes a single file from the list. - * - * @param string $file - * - * @return $this - */ - final public function removeFile(string $file) - { - return $this->removeFiles([$file]); - } - - //-------------------------------------------------------------------- - - /** - * Verifies and adds files from each - * directory to the list. - * - * @param string[] $directories - * @param bool $recursive - * - * @return $this - */ - final public function addDirectories(array $directories, bool $recursive = false) - { - foreach ($directories as $directory) - { - $this->addDirectory($directory, $recursive); - } - - return $this; - } - - /** - * Verifies and adds all files from a directory. - * - * @param string $directory - * @param boolean $recursive - * - * @return $this - */ - final public function addDirectory(string $directory, bool $recursive = false) - { - $directory = self::resolveDirectory($directory); - - // Map the directory to depth 2 to so directories become arrays - foreach (directory_map($directory, 2, true) as $key => $path) - { - if (is_string($path)) - { - $this->addFile($directory . $path); - } - elseif ($recursive && is_array($path)) - { - $this->addDirectory($directory . $key, true); - } - } - - return $this; - } - //-------------------------------------------------------------------- /** @@ -535,19 +305,9 @@ final public function addPaths(array $paths, bool $recursive = true) */ final public function addPath(string $path, bool $recursive = true) { - $full = $this->source . $path; - - // Test for a directory - try - { - $directory = self::resolveDirectory($full); - } - catch (PublisherException $e) - { - return $this->addFile($full); - } + $this->add($this->source . $path, $recursive); - return $this->addDirectory($full, $recursive); + return $this; } //-------------------------------------------------------------------- @@ -589,54 +349,6 @@ final public function addUri(string $uri) //-------------------------------------------------------------------- - /** - * Removes any files from the list that match the supplied pattern - * (within the optional scope). - * - * @param string $pattern Regex or pseudo-regex string - * @param string|null $scope The directory to limit the scope - * - * @return $this - */ - final public function removePattern(string $pattern, string $scope = null) - { - if ($pattern === '') - { - return $this; - } - - // Start with all files or those in scope - $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); - - // Remove any files that match the pattern - return $this->removeFiles(self::matchFiles($files, $pattern)); - } - - /** - * Keeps only the files from the list that match - * (within the optional scope). - * - * @param string $pattern Regex or pseudo-regex string - * @param string|null $scope A directory to limit the scope - * - * @return $this - */ - final public function retainPattern(string $pattern, string $scope = null) - { - if ($pattern === '') - { - return $this; - } - - // Start with all files or those in scope - $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); - - // Matches the pattern within the scoped files and remove their inverse. - return $this->removeFiles(array_diff($files, self::matchFiles($files, $pattern))); - } - - //-------------------------------------------------------------------- - /** * Removes the destination and all its files and folders. * @@ -662,7 +374,7 @@ final public function copy(bool $replace = true): bool { $this->errors = $this->published = []; - foreach ($this->getFiles() as $file) + foreach ($this->get() as $file) { $to = $this->destination . basename($file); @@ -693,7 +405,7 @@ final public function merge(bool $replace = true): bool $this->errors = $this->published = []; // Get the files from source for special handling - $sourced = self::filterFiles($this->getFiles(), $this->source); + $sourced = self::filterFiles($this->get(), $this->source); // Handle everything else with a flat copy $this->files = array_diff($this->files, $sourced); diff --git a/tests/system/Files/FileCollectionTest.php b/tests/system/Files/FileCollectionTest.php new file mode 100644 index 000000000000..3e77941d5d2f --- /dev/null +++ b/tests/system/Files/FileCollectionTest.php @@ -0,0 +1,536 @@ +getPrivateMethodInvoker(FileCollection::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($this->directory)); + } + + public function testResolveDirectoryFile() + { + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveDirectory'); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedDirectory', ['invokeArgs'])); + + $method($this->file); + } + + public function testResolveDirectorySymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->directory, $link); + + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($link)); + + unlink($link); + } + + //-------------------------------------------------------------------- + + public function testResolveFileFile() + { + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveFile'); + + $this->assertSame($this->file, $method($this->file)); + } + + public function testResolveFileSymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->file, $link); + + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveFile'); + + $this->assertSame($this->file, $method($link)); + + unlink($link); + } + + public function testResolveFileDirectory() + { + $method = $this->getPrivateMethodInvoker(FileCollection::class, 'resolveFile'); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['invokeArgs'])); + + $method($this->directory); + } + + //-------------------------------------------------------------------- + + public function testAddStringFile() + { + $files = new FileCollection(); + + $files->add(SUPPORTPATH . 'Files/baker/banana.php'); + + $this->assertSame([$this->file], $files->get()); + } + + public function testAddStringFileRecursiveDoesNothing() + { + $files = new FileCollection(); + + $files->add(SUPPORTPATH . 'Files/baker/banana.php', true); + + $this->assertSame([$this->file], $files->get()); + } + + public function testAddStringDirectory() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $files->add(SUPPORTPATH . 'Files/able'); + + $this->assertSame($expected, $files->get()); + } + + public function testAddStringDirectoryRecursive() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $files->add(SUPPORTPATH . 'Files'); + + $this->assertSame($expected, $files->get()); + } + + public function testAddArray() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $files->add([ + SUPPORTPATH . 'Files/able', + SUPPORTPATH . 'Files/baker/banana.php', + ]); + + $this->assertSame($expected, $files->get()); + } + + public function testAddArrayRecursive() + { + $files = new FileCollection(); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $files->add([ + SUPPORTPATH . 'Files', + SUPPORTPATH . 'Log', + ], true); + + $this->assertSame($expected, $files->get()); + } + + //-------------------------------------------------------------------- + + public function testAddFile() + { + $collection = new FileCollection(); + $this->assertSame([], $this->getPrivateProperty($collection, 'files')); + + $collection->addFile($this->file); + $this->assertSame([$this->file], $this->getPrivateProperty($collection, 'files')); + } + + public function testAddFileMissing() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['addFile'])); + + $collection->addFile('TheHillsAreAlive.bmp'); + } + + public function testAddFileDirectory() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['addFile'])); + + $collection->addFile($this->directory); + } + + public function testAddFiles() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->file, + ]; + + $collection->addFiles($files); + $this->assertSame($files, $this->getPrivateProperty($collection, 'files')); + } + + //-------------------------------------------------------------------- + + public function testGet() + { + $collection = new FileCollection(); + $collection->addFile($this->file); + + $this->assertSame([$this->file], $collection->get()); + } + + public function testGetSorts() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $collection->addFiles($files); + + $this->assertSame(array_reverse($files), $collection->get()); + } + + public function testGetUniques() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->file, + ]; + + $collection->addFiles($files); + $this->assertSame([$this->file], $collection->get()); + } + + public function testSet() + { + $collection = new FileCollection(); + + $collection->set([$this->file]); + $this->assertSame([$this->file], $collection->get()); + } + + public function testSetInvalid() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedFile', ['addFile'])); + + $collection->set(['flerb']); + } + + //-------------------------------------------------------------------- + + public function testRemoveFile() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $collection->addFiles($files); + + $collection->removeFile($this->file); + + $this->assertSame([$this->directory . 'apple.php'], $collection->get()); + } + + public function testRemoveFiles() + { + $collection = new FileCollection(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $collection->addFiles($files); + + $collection->removeFiles($files); + + $this->assertSame([], $collection->get()); + } + + //-------------------------------------------------------------------- + + public function testAddDirectoryInvalid() + { + $collection = new FileCollection(); + + $this->expectException(FileException::class); + $this->expectExceptionMessage(lang('Files.expectedDirectory', ['addDirectory'])); + + $collection->addDirectory($this->file); + } + + public function testAddDirectory() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $collection->addDirectory($this->directory); + + $this->assertSame($expected, $collection->get()); + } + + public function testAddDirectoryRecursive() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $this->assertSame($expected, $collection->get()); + } + + public function testAddDirectories() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->addDirectories([ + $this->directory, + SUPPORTPATH . 'Files/baker', + ]); + + $this->assertSame($expected, $collection->get()); + } + + public function testAddDirectoriesRecursive() + { + $collection = new FileCollection(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $collection->addDirectories([ + SUPPORTPATH . 'Files', + SUPPORTPATH . 'Log', + ], true); + + $this->assertSame($expected, $collection->get()); + } + + //-------------------------------------------------------------------- + + public function testRemovePatternEmpty() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $collection->get(); + + $collection->removePattern(''); + + $this->assertSame($files, $collection->get()); + } + + public function testRemovePatternRegex() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->removePattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRemovePatternPseudo() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->removePattern('*_*.php'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRemovePatternScope() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->removePattern('*.php', $this->directory); + + $this->assertSame($expected, $collection->get()); + } + + //-------------------------------------------------------------------- + + public function testRetainPatternEmpty() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $collection->get(); + + $collection->retainPattern(''); + + $this->assertSame($files, $collection->get()); + } + + public function testRetainPatternRegex() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $collection->retainPattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRetainPatternPseudo() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + ]; + + $collection->retainPattern('*_?.php'); + + $this->assertSame($expected, $collection->get()); + } + + public function testRetainPatternScope() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $collection->retainPattern('*_?.php', $this->directory); + + $this->assertSame($expected, $collection->get()); + } + + //-------------------------------------------------------------------- + + public function testCount() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $this->assertCount(4, $collection); + } + + public function testIterable() + { + $collection = new FileCollection(); + $collection->addDirectory(SUPPORTPATH . 'Files', true); + + $count = 0; + foreach ($collection as $file) + { + $this->assertInstanceOf(File::class, $file); + $count++; + } + + $this->assertSame($count, 4); + } +} diff --git a/tests/system/Publisher/PublisherInputTest.php b/tests/system/Publisher/PublisherInputTest.php index e7450f5c02a9..29d48ef78816 100644 --- a/tests/system/Publisher/PublisherInputTest.php +++ b/tests/system/Publisher/PublisherInputTest.php @@ -35,219 +35,13 @@ public static function setUpBeforeClass(): void //-------------------------------------------------------------------- - public function testAddFile() - { - $publisher = new Publisher(); - $this->assertSame([], $this->getPrivateProperty($publisher, 'files')); - - $publisher->addFile($this->file); - $this->assertSame([$this->file], $this->getPrivateProperty($publisher, 'files')); - } - - public function testAddFileMissing() - { - $publisher = new Publisher(); - - $this->expectException(PublisherException::class); - $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); - - $publisher->addFile('TheHillsAreAlive.bmp'); - } - - public function testAddFileDirectory() - { - $publisher = new Publisher(); - - $this->expectException(PublisherException::class); - $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); - - $publisher->addFile($this->directory); - } - - public function testAddFiles() - { - $publisher = new Publisher(); - $files = [ - $this->file, - $this->file, - ]; - - $publisher->addFiles($files); - $this->assertSame($files, $this->getPrivateProperty($publisher, 'files')); - } - - //-------------------------------------------------------------------- - - public function testGetFiles() - { - $publisher = new Publisher(); - $publisher->addFile($this->file); - - $this->assertSame([$this->file], $publisher->getFiles()); - } - - public function testGetFilesSorts() - { - $publisher = new Publisher(); - $files = [ - $this->file, - $this->directory . 'apple.php', - ]; - - $publisher->addFiles($files); - - $this->assertSame(array_reverse($files), $publisher->getFiles()); - } - - public function testGetFilesUniques() - { - $publisher = new Publisher(); - $files = [ - $this->file, - $this->file, - ]; - - $publisher->addFiles($files); - $this->assertSame([$this->file], $publisher->getFiles()); - } - - public function testSetFiles() - { - $publisher = new Publisher(); - - $publisher->setFiles([$this->file]); - $this->assertSame([$this->file], $publisher->getFiles()); - } - - public function testSetFilesInvalid() - { - $publisher = new Publisher(); - - $this->expectException(PublisherException::class); - $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); - - $publisher->setFiles(['flerb']); - } - - //-------------------------------------------------------------------- - - public function testRemoveFile() - { - $publisher = new Publisher(); - $files = [ - $this->file, - $this->directory . 'apple.php', - ]; - - $publisher->addFiles($files); - - $publisher->removeFile($this->file); - - $this->assertSame([$this->directory . 'apple.php'], $publisher->getFiles()); - } - - public function testRemoveFiles() - { - $publisher = new Publisher(); - $files = [ - $this->file, - $this->directory . 'apple.php', - ]; - - $publisher->addFiles($files); - - $publisher->removeFiles($files); - - $this->assertSame([], $publisher->getFiles()); - } - - //-------------------------------------------------------------------- - - public function testAddDirectoryInvalid() - { - $publisher = new Publisher(); - - $this->expectException(PublisherException::class); - $this->expectExceptionMessage(lang('Publisher.expectedDirectory', ['addDirectory'])); - - $publisher->addDirectory($this->file); - } - - public function testAddDirectory() - { - $publisher = new Publisher(); - $expected = [ - $this->directory . 'apple.php', - $this->directory . 'fig_3.php', - $this->directory . 'prune_ripe.php', - ]; - - $publisher->addDirectory($this->directory); - - $this->assertSame($expected, $publisher->getFiles()); - } - - public function testAddDirectoryRecursive() - { - $publisher = new Publisher(); - $expected = [ - $this->directory . 'apple.php', - $this->directory . 'fig_3.php', - $this->directory . 'prune_ripe.php', - SUPPORTPATH . 'Files/baker/banana.php', - ]; - - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $this->assertSame($expected, $publisher->getFiles()); - } - - public function testAddDirectories() - { - $publisher = new Publisher(); - $expected = [ - $this->directory . 'apple.php', - $this->directory . 'fig_3.php', - $this->directory . 'prune_ripe.php', - SUPPORTPATH . 'Files/baker/banana.php', - ]; - - $publisher->addDirectories([ - $this->directory, - SUPPORTPATH . 'Files/baker', - ]); - - $this->assertSame($expected, $publisher->getFiles()); - } - - public function testAddDirectoriesRecursive() - { - $publisher = new Publisher(); - $expected = [ - $this->directory . 'apple.php', - $this->directory . 'fig_3.php', - $this->directory . 'prune_ripe.php', - SUPPORTPATH . 'Files/baker/banana.php', - SUPPORTPATH . 'Log/Handlers/TestHandler.php', - ]; - - $publisher->addDirectories([ - SUPPORTPATH . 'Files', - SUPPORTPATH . 'Log', - ], true); - - $this->assertSame($expected, $publisher->getFiles()); - } - - //-------------------------------------------------------------------- - public function testAddPathFile() { $publisher = new Publisher(SUPPORTPATH . 'Files'); $publisher->addPath('baker/banana.php'); - $this->assertSame([$this->file], $publisher->getFiles()); + $this->assertSame([$this->file], $publisher->get()); } public function testAddPathFileRecursiveDoesNothing() @@ -256,7 +50,7 @@ public function testAddPathFileRecursiveDoesNothing() $publisher->addPath('baker/banana.php', true); - $this->assertSame([$this->file], $publisher->getFiles()); + $this->assertSame([$this->file], $publisher->get()); } public function testAddPathDirectory() @@ -271,7 +65,7 @@ public function testAddPathDirectory() $publisher->addPath('able'); - $this->assertSame($expected, $publisher->getFiles()); + $this->assertSame($expected, $publisher->get()); } public function testAddPathDirectoryRecursive() @@ -287,7 +81,7 @@ public function testAddPathDirectoryRecursive() $publisher->addPath('Files'); - $this->assertSame($expected, $publisher->getFiles()); + $this->assertSame($expected, $publisher->get()); } public function testAddPaths() @@ -306,7 +100,7 @@ public function testAddPaths() 'baker/banana.php', ]); - $this->assertSame($expected, $publisher->getFiles()); + $this->assertSame($expected, $publisher->get()); } public function testAddPathsRecursive() @@ -326,7 +120,7 @@ public function testAddPathsRecursive() 'Log', ], true); - $this->assertSame($expected, $publisher->getFiles()); + $this->assertSame($expected, $publisher->get()); } //-------------------------------------------------------------------- @@ -338,7 +132,7 @@ public function testAddUri() $scratch = $this->getPrivateProperty($publisher, 'scratch'); - $this->assertSame([$scratch . 'composer.json'], $publisher->getFiles()); + $this->assertSame([$scratch . 'composer.json'], $publisher->get()); } public function testAddUris() @@ -351,122 +145,6 @@ public function testAddUris() $scratch = $this->getPrivateProperty($publisher, 'scratch'); - $this->assertSame([$scratch . 'LICENSE', $scratch . 'composer.json'], $publisher->getFiles()); - } - - //-------------------------------------------------------------------- - - public function testRemovePatternEmpty() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $files = $publisher->getFiles(); - - $publisher->removePattern(''); - - $this->assertSame($files, $publisher->getFiles()); - } - - public function testRemovePatternRegex() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $expected = [ - $this->directory . 'apple.php', - SUPPORTPATH . 'Files/baker/banana.php', - ]; - - $publisher->removePattern('#[a-z]+_.*#'); - - $this->assertSame($expected, $publisher->getFiles()); - } - - public function testRemovePatternPseudo() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $expected = [ - $this->directory . 'apple.php', - SUPPORTPATH . 'Files/baker/banana.php', - ]; - - $publisher->removePattern('*_*.php'); - - $this->assertSame($expected, $publisher->getFiles()); - } - - public function testRemovePatternScope() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $expected = [ - SUPPORTPATH . 'Files/baker/banana.php', - ]; - - $publisher->removePattern('*.php', $this->directory); - - $this->assertSame($expected, $publisher->getFiles()); - } - - //-------------------------------------------------------------------- - - public function testRetainPatternEmpty() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $files = $publisher->getFiles(); - - $publisher->retainPattern(''); - - $this->assertSame($files, $publisher->getFiles()); - } - - public function testRetainPatternRegex() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $expected = [ - $this->directory . 'fig_3.php', - $this->directory . 'prune_ripe.php', - ]; - - $publisher->retainPattern('#[a-z]+_.*#'); - - $this->assertSame($expected, $publisher->getFiles()); - } - - public function testRetainPatternPseudo() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $expected = [ - $this->directory . 'fig_3.php', - ]; - - $publisher->retainPattern('*_?.php'); - - $this->assertSame($expected, $publisher->getFiles()); - } - - public function testRetainPatternScope() - { - $publisher = new Publisher(); - $publisher->addDirectory(SUPPORTPATH . 'Files', true); - - $expected = [ - $this->directory . 'fig_3.php', - SUPPORTPATH . 'Files/baker/banana.php', - ]; - - $publisher->retainPattern('*_?.php', $this->directory); - - $this->assertSame($expected, $publisher->getFiles()); + $this->assertSame([$scratch . 'LICENSE', $scratch . 'composer.json'], $publisher->get()); } } diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index 42f22d721525..081b831f1241 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -53,75 +53,11 @@ public function testDiscoverNothing() public function testDiscoverStores() { $publisher = Publisher::discover()[0]; - $publisher->setFiles([])->addFile($this->file); + $publisher->set([])->addFile($this->file); $result = Publisher::discover(); $this->assertSame($publisher, $result[0]); - $this->assertSame([$this->file], $result[0]->getFiles()); - } - - //-------------------------------------------------------------------- - - public function testResolveDirectoryDirectory() - { - $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); - - $this->assertSame($this->directory, $method($this->directory)); - } - - public function testResolveDirectoryFile() - { - $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); - - $this->expectException(PublisherException::class); - $this->expectExceptionMessage(lang('Publisher.expectedDirectory', ['invokeArgs'])); - - $method($this->file); - } - - public function testResolveDirectorySymlink() - { - // Create a symlink to test - $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); - symlink($this->directory, $link); - - $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); - - $this->assertSame($this->directory, $method($link)); - - unlink($link); - } - - //-------------------------------------------------------------------- - - public function testResolveFileFile() - { - $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); - - $this->assertSame($this->file, $method($this->file)); - } - - public function testResolveFileSymlink() - { - // Create a symlink to test - $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); - symlink($this->file, $link); - - $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); - - $this->assertSame($this->file, $method($link)); - - unlink($link); - } - - public function testResolveFileDirectory() - { - $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); - - $this->expectException(PublisherException::class); - $this->expectExceptionMessage(lang('Publisher.expectedFile', ['invokeArgs'])); - - $method($this->directory); + $this->assertSame([$this->file], $result[0]->get()); } //-------------------------------------------------------------------- diff --git a/user_guide_src/source/libraries/files.rst b/user_guide_src/source/libraries/files.rst index e6482642d698..88271703b574 100644 --- a/user_guide_src/source/libraries/files.rst +++ b/user_guide_src/source/libraries/files.rst @@ -109,3 +109,99 @@ The move() method returns a new File instance that for the relocated file, so yo resulting location is needed:: $file = $file->move(WRITEPATH.'uploads'); + +**************** +File Collections +**************** + +Working with groups of files can be cumbersome, so the framework supplies the ``FileCollection`` class to facilitate +locating and working with groups of files across the filesystem. At its most basic, ``FileCollection`` is an index +of files you set or build:: + + $files = new FileCollection([ + FCPATH . 'index.php', + ROOTPATH . 'spark', + ]); + $files->addDirectory(APPPATH . 'Filters'); + +After you have input the files you would like to work with you may remove files or use the filtering commands to remove +or retain files matching a certain regex or glob-style pattern:: + + $files->removeFile(APPPATH . 'Filters/DevelopToolbar'); + + $files->removePattern('#\.gitkeep#'); + $files->retainPattern('*.php'); + +When your collection is complete, you can use ``get()`` to retrieve the final list of file paths, or take advantage of +``FileCollection`` being countable and iterable to work directly with each ``File``:: + + echo 'My files: ' . implode(PHP_EOL, $files->get()); + echo 'I have ' . count($files) . ' files!'; + + foreach ($files as $file) + { + echo 'Moving ' . $file->getBasename() . ', ' . $file->getSizeByUnit('mb'); + $file->move(WRITABLE . $file->getRandomName()); + } + +Below are the specific methods for working with a ``FileCollection``. + +Inputting Files +=============== + +**set(array $files)** + +Sets the list of input files to the provided string array of file paths. + +**add(string[]|string $paths, bool $recursive = true)** + +Adds all files indicated by the path or array of paths. If the path resolves to a directory then ``$recursive`` +will include sub-directories. + +**addFile(string $file)** +**addFiles(array $files)** + +Adds the file or files to the current list of input files. Files are absolute paths to actual files. + +**removeFile(string $file)** +**removeFiles(array $files)** + +Removes the file or files from the current list of input files. + +**addDirectory(string $directory, bool $recursive = false)** +**addDirectories(array $directories, bool $recursive = false)** + +Adds all files from the directory or directories, optionally recursing into sub-directories. Directories are +absolute paths to actual directories. + +Filtering Files +=============== + +**removePattern(string $pattern, string $scope = null)** +**retainPattern(string $pattern, string $scope = null)** + +Filters the current file list through the pattern (and optional scope), removing or retaining matched +files. ``$pattern`` may be a complete regex (like ``'#[A-Za-z]+\.php#'``) or a pseudo-regex similar +to ``glob()`` (like ``*.css``). +If a ``$scope`` is provided then only files in or under that directory will be considered (i.e. files +outside of ``$scope`` are always retained). When no scope is provided then all files are subject. + +Examples:: + + $files = new FileCollection(); + $files->add(APPPATH . 'Config', true); // Adds all Config files and directories + + $files->removePattern('*tion.php'); // Would remove Encryption.php, Validation.php, and boot/production.php + $files->removePattern('*tion.php', APPPATH . 'Config/boot'); // Would only remove boot/production.php + + $files->retainPattern('#A.+php$#'); // Would keep only Autoload.php + $files->retainPattern('#d.+php$#', APPPATH . 'Config/boot'); // Would keep everything but boot/production.php and boot/testing.php + +Retrieving Files +================ + +**get(): string[]** + +Returns an array of all the loaded input files. + +.. note:: ``FileCollection`` is an ``IteratorAggregate`` so you can work with it directly (e.g. ``foreach ($collection as $file)``). diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index 84b430ba144b..dc2ec08347a8 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -28,8 +28,8 @@ Concept and Usage * How can I update my project when the framework or modules change? * How can components inject new content into existing projects? -At its most basic, publishing amounts to copying a file or files into a project. ``Publisher`` uses fluent-style -command chaining to read, filter, and process input files, then copies or merges them into the target destination. +At its most basic, publishing amounts to copying a file or files into a project. ``Publisher`` extends ``FileCollection`` +to enact fluent-style command chaining to read, filter, and process input files, then copies or merges them into the target destination. You may use ``Publisher`` on demand in your Controllers or other components, or you may stage publications by extending the class and leveraging its discovery with ``spark publish``. @@ -320,6 +320,8 @@ Now when your module users run ``php spark auth:publish`` they will have the fol Library Reference ***************** +.. note:: ``Publisher`` is an extension of :doc:`FileCollection ` so has access to all those methods for reading and filtering files. + Support Methods =============== @@ -346,33 +348,6 @@ files and changes, and this provides the path to a transient, writable directory Returns any errors from the last write operation. The array keys are the files that caused the error, and the values are the Throwable that was caught. Use ``getMessage()`` on the Throwable to get the error message. -**getFiles(): string[]** - -Returns an array of all the loaded input files. - -Inputting Files -=============== - -**setFiles(array $files)** - -Sets the list of input files to the provided string array of file paths. - -**addFile(string $file)** -**addFiles(array $files)** - -Adds the file or files to the current list of input files. Files are absolute paths to actual files. - -**removeFile(string $file)** -**removeFiles(array $files)** - -Removes the file or files from the current list of input files. - -**addDirectory(string $directory, bool $recursive = false)** -**addDirectories(array $directories, bool $recursive = false)** - -Adds all files from the directory or directories, optionally recursing into sub-directories. Directories are -absolute paths to actual directories. - **addPath(string $path, bool $recursive = true)** **addPaths(array $path, bool $recursive = true)** @@ -388,29 +363,6 @@ file to the list. .. note:: The CURL request made is a simple ``GET`` and uses the response body for the file contents. Some remote files may need a custom request to be handled properly. -Filtering Files -=============== - -**removePattern(string $pattern, string $scope = null)** -**retainPattern(string $pattern, string $scope = null)** - -Filters the current file list through the pattern (and optional scope), removing or retaining matched -files. ``$pattern`` may be a complete regex (like ``'#[A-Za-z]+\.php#'``) or a pseudo-regex similar -to ``glob()`` (like ``*.css``). -If a ``$scope`` is provided then only files in or under that directory will be considered (i.e. files -outside of ``$scope`` are always retained). When no scope is provided then all files are subject. - -Examples:: - - $publisher = new Publisher(APPPATH . 'Config'); - $publisher->addPath('/', true); // Adds all Config files and directories - - $publisher->removePattern('*tion.php'); // Would remove Encryption.php, Validation.php, and boot/production.php - $publisher->removePattern('*tion.php', APPPATH . 'Config/boot'); // Would only remove boot/production.php - - $publisher->retainPattern('#A.+php$#'); // Would keep only Autoload.php - $publisher->retainPattern('#d.+php$#', APPPATH . 'Config/boot'); // Would keep everything but boot/production.php and boot/testing.php - Outputting Files ================ From 9e4f9fd0c95fb4da9278eceab3dec0dabb4edb41 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Sun, 13 Jun 2021 23:37:10 -0500 Subject: [PATCH 15/65] New mock() test helper and expanded MockCache with assertions. --- system/Cache/CacheFactory.php | 17 +++- system/Exceptions/TestException.php | 25 ++++++ system/Helpers/test_helper.php | 31 +++++++ system/Language/en/Test.php | 15 ++++ system/Test/Mock/MockCache.php | 98 +++++++++++++++++------ tests/system/Cache/CacheMockTest.php | 39 +++++++++ user_guide_src/source/testing/index.rst | 3 +- user_guide_src/source/testing/mocking.rst | 53 ++++++++++++ 8 files changed, 255 insertions(+), 26 deletions(-) create mode 100644 system/Exceptions/TestException.php create mode 100644 system/Language/en/Test.php create mode 100644 tests/system/Cache/CacheMockTest.php create mode 100644 user_guide_src/source/testing/mocking.rst diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php index e77961313827..897096330220 100644 --- a/system/Cache/CacheFactory.php +++ b/system/Cache/CacheFactory.php @@ -13,6 +13,7 @@ use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\Exceptions\CriticalError; +use CodeIgniter\Test\Mock\MockCache; use Config\Cache; /** @@ -22,6 +23,20 @@ */ class CacheFactory { + /** + * The class to use when mocking + * + * @var string + */ + public static $mockClass = MockCache::class; + + /** + * The service to inject the mock as + * + * @var string + */ + public static $mockServiceName = 'cache'; + /** * Attempts to create the desired cache handler, based upon the * @@ -83,6 +98,4 @@ public static function getHandler(Cache $config, string $handler = null, string return $adapter; } - - //-------------------------------------------------------------------- } diff --git a/system/Exceptions/TestException.php b/system/Exceptions/TestException.php new file mode 100644 index 000000000000..fc270f2aa24d --- /dev/null +++ b/system/Exceptions/TestException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Exceptions; + +/** + * Exception for automatic logging. + */ +class TestException extends CriticalError +{ + use DebugTraceableTrait; + + public static function forInvalidMockClass(string $name) + { + return new static(lang('Test.invalidMockClass', [$name])); + } +} diff --git a/system/Helpers/test_helper.php b/system/Helpers/test_helper.php index c6f9bebe7577..5788e70027f1 100644 --- a/system/Helpers/test_helper.php +++ b/system/Helpers/test_helper.php @@ -9,7 +9,9 @@ * file that was distributed with this source code. */ +use CodeIgniter\Exceptions\TestException; use CodeIgniter\Test\Fabricator; +use Config\Services; /** * CodeIgniter Test Helpers @@ -45,3 +47,32 @@ function fake($model, array $overrides = null, $persist = true) return $fabricator->make(); } } + +if (! function_exists('mock')) +{ + /** + * Used within our test suite to mock certain system tools. + * All tools using this MUST use the MockableTrait + * + * @param string $className Fully qualified class name + */ + function mock(string $className) + { + $mockClass = $className::$mockClass; + $mockService = $className::$mockServiceName; + + if (empty($mockClass) || ! class_exists($mockClass)) + { + throw TestException::forInvalidMockClass($mockClass); + } + + $mock = new $mockClass(); + + if (! empty($mockService)) + { + Services::injectMock($mockService, $mock); + } + + return $mock; + } +} diff --git a/system/Language/en/Test.php b/system/Language/en/Test.php new file mode 100644 index 000000000000..8df1eb8c2f8c --- /dev/null +++ b/system/Language/en/Test.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// Testing language settings +return [ + 'invalidMockClass' => '{0} is not a valid Mock class', +]; diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index f4fb1a972c5d..b8f05760a3a6 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -13,6 +13,7 @@ use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\Handlers\BaseHandler; +use PHPUnit\Framework\Assert; use Closure; class MockCache extends BaseHandler implements CacheInterface @@ -31,7 +32,12 @@ class MockCache extends BaseHandler implements CacheInterface */ protected $expirations = []; - //-------------------------------------------------------------------- + /** + * If true, will not cache any data. + * + * @var boolean + */ + protected $bypass = false; /** * Takes care of any handler-specific setup that must be done. @@ -40,8 +46,6 @@ public function initialize() { } - //-------------------------------------------------------------------- - /** * Attempts to fetch an item from the cache store. * @@ -58,8 +62,6 @@ public function get(string $key) : null; } - //-------------------------------------------------------------------- - /** * Get an item from the cache, or execute the given Closure and store the result. * @@ -83,8 +85,6 @@ public function remember(string $key, int $ttl, Closure $callback) return $value; } - //-------------------------------------------------------------------- - /** * Saves an item to the cache store. * @@ -100,6 +100,11 @@ public function remember(string $key, int $ttl, Closure $callback) */ public function save(string $key, $value, int $ttl = 60, bool $raw = false) { + if ($this->bypass) + { + return false; + } + $key = static::validateKey($key, $this->prefix); $this->cache[$key] = $value; @@ -108,8 +113,6 @@ public function save(string $key, $value, int $ttl = 60, bool $raw = false) return true; } - //-------------------------------------------------------------------- - /** * Deletes a specific item from the cache store. * @@ -132,8 +135,6 @@ public function delete(string $key) return true; } - //-------------------------------------------------------------------- - /** * Deletes items from the cache store matching a given pattern. * @@ -157,8 +158,6 @@ public function deleteMatching(string $pattern) return $count; } - //-------------------------------------------------------------------- - /** * Performs atomic incrementation of a raw stored value. * @@ -184,8 +183,6 @@ public function increment(string $key, int $offset = 1) return $this->save($key, $data + $offset); } - //-------------------------------------------------------------------- - /** * Performs atomic decrementation of a raw stored value. * @@ -212,8 +209,6 @@ public function decrement(string $key, int $offset = 1) return $this->save($key, $data - $offset); } - //-------------------------------------------------------------------- - /** * Will delete all items in the entire cache. * @@ -227,8 +222,6 @@ public function clean() return true; } - //-------------------------------------------------------------------- - /** * Returns information on the entire cache. * @@ -242,8 +235,6 @@ public function getCacheInfo() return array_keys($this->cache); } - //-------------------------------------------------------------------- - /** * Returns detailed information about the specific item in the cache. * @@ -272,8 +263,6 @@ public function getMetaData(string $key) ]; } - //-------------------------------------------------------------------- - /** * Determines if the driver is supported on this system. * @@ -284,6 +273,69 @@ public function isSupported(): bool return true; } + //-------------------------------------------------------------------- + // Test Helpers //-------------------------------------------------------------------- + /** + * Instructs the class to ignore all + * requests to cache an item, and always "miss" + * when checked for existing data. + * + * @return $this + */ + public function bypass(bool $bypass = true) + { + $this->clean(); + + $this->bypass = $bypass; + + return $this; + } + + //-------------------------------------------------------------------- + // Additional Assertions + //-------------------------------------------------------------------- + + /** + * Asserts that the cache has an item named $key. + * The value is not checked since storing false or null + * values is valid. + * + * @param string $key + */ + public function assertHas(string $key) + { + Assert::assertNotNull($this->get($key), "The cache does not have an item named: `{$key}`"); + } + + /** + * Asserts that the cache has an item named $key with a value matching $value. + * + * @param string $key + * @param null $value + */ + public function assertHasValue(string $key, $value = null) + { + $item = $this->get($key); + + // Let assertHas handle throwing the error for consistency + // if the key is not found + if (empty($item)) + { + $this->assertHas($key); + } + + Assert::assertEquals($value, $this->get($key), "The cached item `{$key}` does not equal match expectation. Found: " . print_r($value, true)); + } + + /** + * Asserts that the cache does NOT have an item named $key. + * + * @param string $key + */ + public function assertMissing(string $key) + { + Assert::assertFalse(array_key_exists($key, $this->cache), "The cached item named `{$key}` exists."); + } } diff --git a/tests/system/Cache/CacheMockTest.php b/tests/system/Cache/CacheMockTest.php new file mode 100644 index 000000000000..7112f5bf972b --- /dev/null +++ b/tests/system/Cache/CacheMockTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(BaseHandler::class, service('cache')); + + $mock = mock(CacheFactory::class); + + // Should return MockCache class + $this->assertInstanceOf(MockCache::class, $mock); + + // Should inject MockCache + $this->assertInstanceOf(MockCache::class, service('cache')); + } + + public function testMockCaching() + { + $mock = mock(CacheFactory::class); + + // Ensure it stores the value normally + $mock->save('foo', 'bar'); + $mock->assertHas('foo'); + $mock->assertHasValue('foo', 'bar'); + + // Try it again with bypass on + $mock->bypass(); + $mock->save('foo', 'bar'); + $mock->assertMissing('foo'); + } +} diff --git a/user_guide_src/source/testing/index.rst b/user_guide_src/source/testing/index.rst index 878cc9918355..949a68b99b51 100644 --- a/user_guide_src/source/testing/index.rst +++ b/user_guide_src/source/testing/index.rst @@ -2,7 +2,7 @@ Testing ####### -CodeIgniter ships with a number of tools to help you test and debug your application thoroughly. +CodeIgniter ships with a number of tools to help you test and debug your application thoroughly. The following sections should get you quickly testing your applications. .. toctree:: @@ -16,3 +16,4 @@ The following sections should get you quickly testing your applications. response benchmark debugging + Mocking diff --git a/user_guide_src/source/testing/mocking.rst b/user_guide_src/source/testing/mocking.rst new file mode 100644 index 000000000000..ea0a30922dd4 --- /dev/null +++ b/user_guide_src/source/testing/mocking.rst @@ -0,0 +1,53 @@ +###################### +Mocking System Classes +###################### + +Several classes within the framework provide mocked versions of the classes that can be used during testing. These classes +can take the place of the normal class during test execution, often providing additional assertions to test that actions +have taken place (or not taken place) during the execution of the test. This might be checking data gets cached correctly, +emails were sent correctly, etc. + +.. contents:: + :local: + :depth: 1 + +Cache +===== + +You can mock the cache with the ``mock()`` method, using the ``CacheFactory`` as its only parameter. +:: + + $mock = mock(CodeIgniter\Cache\CacheFactory::class); + +While this returns an instance of ``CodeIgniter\Test\Mock\MockCache`` that you can use directly, it also inserts the +mock into the Service class, so any calls within your code to ``service('cache')`` or ``Config\Services::cache()`` will +use the mocked class within its place. + +When using this in more than one test method within a single file you should call either the ``clean()`` or ``bypass()`` +methods during the test ``setUp()`` to ensure a clean slate when your tests run. + +Additional Methods +------------------ + +You can instruct the mocked cache handler to never do any caching with the ``bypass()`` method. This will emulate +using the dummy handler and ensures that your test does not rely on cached data for your tests. +:: + + $mock = mock(CodeIgniter\Cache\CacheFactory::class); + // Never cache any items during this test. + $mock->bypass(); + +Available Assertions +-------------------- + +The following new assertions are available on the mocked class for using during testing: +:: + + $mock = mock(CodeIgniter\Cache\CacheFactory::class); + + // Assert that a cached item named $key exists + $mock->assertHas($key); + // Assert that a cached item named $key exists with a value of $value + $mock->assertHasValue($key, $value); + // Assert that a cached item named $key does NOT exist + $mock->assertMissing($key); From e019a7fee9df83bad8cf7434634d2c8eb32366ee Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Mon, 14 Jun 2021 08:13:33 -0500 Subject: [PATCH 16/65] Update system/Helpers/test_helper.php Co-authored-by: MGatner --- system/Helpers/test_helper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/system/Helpers/test_helper.php b/system/Helpers/test_helper.php index 5788e70027f1..d33f9681b0d7 100644 --- a/system/Helpers/test_helper.php +++ b/system/Helpers/test_helper.php @@ -52,7 +52,6 @@ function fake($model, array $overrides = null, $persist = true) { /** * Used within our test suite to mock certain system tools. - * All tools using this MUST use the MockableTrait * * @param string $className Fully qualified class name */ From 55966e8fb433d2d1c2252f5224107e785b82fd2a Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Mon, 14 Jun 2021 08:13:52 -0500 Subject: [PATCH 17/65] Update system/Helpers/test_helper.php Co-authored-by: MGatner --- system/Helpers/test_helper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/Helpers/test_helper.php b/system/Helpers/test_helper.php index d33f9681b0d7..013ee9ff88aa 100644 --- a/system/Helpers/test_helper.php +++ b/system/Helpers/test_helper.php @@ -58,7 +58,7 @@ function fake($model, array $overrides = null, $persist = true) function mock(string $className) { $mockClass = $className::$mockClass; - $mockService = $className::$mockServiceName; + $mockService = $className::$mockServiceName ?? ''; if (empty($mockClass) || ! class_exists($mockClass)) { From a2fa932611a6406fc5196f26883277437662dd09 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Mon, 14 Jun 2021 08:17:05 -0500 Subject: [PATCH 18/65] Update user_guide_src/source/testing/mocking.rst Co-authored-by: MGatner --- user_guide_src/source/testing/mocking.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_guide_src/source/testing/mocking.rst b/user_guide_src/source/testing/mocking.rst index ea0a30922dd4..03b464cf6570 100644 --- a/user_guide_src/source/testing/mocking.rst +++ b/user_guide_src/source/testing/mocking.rst @@ -2,7 +2,7 @@ Mocking System Classes ###################### -Several classes within the framework provide mocked versions of the classes that can be used during testing. These classes +Several components within the framework provide mocked versions of their classes that can be used during testing. These classes can take the place of the normal class during test execution, often providing additional assertions to test that actions have taken place (or not taken place) during the execution of the test. This might be checking data gets cached correctly, emails were sent correctly, etc. From 157cb3b89b8ebaf94b2f1535b78c0a3e2fa71d21 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 15 Jun 2021 13:29:46 +0000 Subject: [PATCH 19/65] Implement define() --- system/Files/FileCollection.php | 14 ++++++++-- tests/system/Files/FileCollectionTest.php | 32 +++++++++++++++++++++++ user_guide_src/source/libraries/files.rst | 31 +++++++++++++++++++--- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php index bf4eeaf6b836..fcf2451d357d 100644 --- a/system/Files/FileCollection.php +++ b/system/Files/FileCollection.php @@ -122,7 +122,7 @@ protected static function matchFiles(array $files, string $pattern): array //-------------------------------------------------------------------- /** - * Loads the Filesystem helper and stores initial files. + * Loads the Filesystem helper and adds any initial files. * * @param string[] $files */ @@ -130,7 +130,17 @@ public function __construct(array $files = []) { helper(['filesystem']); - $this->set($files); + $this->add($files)->define(); + } + + /** + * Applies any initial inputs after the constructor. + * This method is a stub to be implemented by child classes. + * + * @return void + */ + protected function define(): void + { } /** diff --git a/tests/system/Files/FileCollectionTest.php b/tests/system/Files/FileCollectionTest.php index 3e77941d5d2f..a650bc464b01 100644 --- a/tests/system/Files/FileCollectionTest.php +++ b/tests/system/Files/FileCollectionTest.php @@ -99,6 +99,38 @@ public function testResolveFileDirectory() //-------------------------------------------------------------------- + public function testConstructorAddsFiles() + { + $expected = [ + $this->directory . 'apple.php', + $this->file, + ]; + + $collection = new class([$this->file]) extends FileCollection { + + protected $files = [ + SUPPORTPATH . 'Files/able/apple.php', + ]; + }; + + $this->assertSame($expected, $collection->get()); + } + + public function testConstructorCallsDefine() + { + $collection = new class([$this->file]) extends FileCollection { + + protected function define(): void + { + $this->add(SUPPORTPATH . 'Files/baker/banana.php'); + } + }; + + $this->assertSame([$this->file], $collection->get()); + } + + //-------------------------------------------------------------------- + public function testAddStringFile() { $files = new FileCollection(); diff --git a/user_guide_src/source/libraries/files.rst b/user_guide_src/source/libraries/files.rst index 88271703b574..d80d31f03a9d 100644 --- a/user_guide_src/source/libraries/files.rst +++ b/user_guide_src/source/libraries/files.rst @@ -146,12 +146,37 @@ When your collection is complete, you can use ``get()`` to retrieve the final li Below are the specific methods for working with a ``FileCollection``. -Inputting Files -=============== +Starting a Collection +===================== + +**__construct(string[] $files = [])** + +The constructor accepts an optional array of file paths to use as the initial collection. These are passed to +**add()** so any files supplied by child classes in the **$files** will remain. + +**define()** + +Allows child classes to define their own initial files. This method is called by the constructor and allows +predefined collections without having to use their methods. Example:: + + class ConfigCollection extends \CodeIgniter\Files\FileCollection + { + protected function define(): void { + + $this->add(APPPATH . 'Config', true)->retainPattern('*.php'); + } + } + +Now you may use the ``ConfigCollection`` anywhere in your project to access all App Config files without +having to re-call the collection methods every time. **set(array $files)** -Sets the list of input files to the provided string array of file paths. +Sets the list of input files to the provided string array of file paths. This will remove any existing +files from the collection, so ``$collection->set([])`` is essentially a hard reset. + +Inputting Files +=============== **add(string[]|string $paths, bool $recursive = true)** From 4e2ecf3cd84d11d6169c43306fc7982bf149e28c Mon Sep 17 00:00:00 2001 From: Toto Prayogo Date: Tue, 15 Jun 2021 11:30:43 +0700 Subject: [PATCH 20/65] rename `application` to `app` --- user_guide_src/source/libraries/sessions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_guide_src/source/libraries/sessions.rst b/user_guide_src/source/libraries/sessions.rst index 2020430012aa..623637d08b18 100644 --- a/user_guide_src/source/libraries/sessions.rst +++ b/user_guide_src/source/libraries/sessions.rst @@ -610,7 +610,7 @@ setting**. The examples below work both on MySQL and PostgreSQL:: ALTER TABLE ci_sessions DROP PRIMARY KEY; You can choose the Database group to use by adding a new line to the -**application\Config\App.php** file with the name of the group to use:: +**app/Config/App.php** file with the name of the group to use:: public $sessionDBGroup = 'groupName'; From ad997789a7f8c16c4f839bcf03764d9ba08c78b6 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 15 Jun 2021 13:43:37 +0000 Subject: [PATCH 21/65] Declare final methods --- system/Files/FileCollection.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php index fcf2451d357d..f7e7c67324c7 100644 --- a/system/Files/FileCollection.php +++ b/system/Files/FileCollection.php @@ -45,7 +45,7 @@ class FileCollection implements Countable, IteratorAggregate * * @throws FileException */ - protected static function resolveDirectory(string $directory): string + final protected static function resolveDirectory(string $directory): string { if (! is_dir($directory = set_realpath($directory))) { @@ -65,7 +65,7 @@ protected static function resolveDirectory(string $directory): string * * @throws FileException */ - protected static function resolveFile(string $file): string + final protected static function resolveFile(string $file): string { if (! is_file($file = set_realpath($file))) { @@ -84,7 +84,7 @@ protected static function resolveFile(string $file): string * * @return string[] */ - protected static function filterFiles(array $files, string $directory): array + final protected static function filterFiles(array $files, string $directory): array { $directory = self::resolveDirectory($directory); @@ -101,7 +101,7 @@ protected static function filterFiles(array $files, string $directory): array * * @return string[] */ - protected static function matchFiles(array $files, string $pattern): array + final protected static function matchFiles(array $files, string $pattern): array { // Convert pseudo-regex into their true form if (@preg_match($pattern, null) === false) // @phpstan-ignore-line From e1a7506ac30bc0cb9f4160a5f8c06101fe0e9ec5 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 15 Jun 2021 11:12:53 -0400 Subject: [PATCH 22/65] Apply suggestions from code review Co-authored-by: John Paul E. Balandan, CPA <51850998+paulbalandan@users.noreply.github.com> --- system/Files/FileCollection.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php index f7e7c67324c7..4231c5776642 100644 --- a/system/Files/FileCollection.php +++ b/system/Files/FileCollection.php @@ -88,7 +88,7 @@ final protected static function filterFiles(array $files, string $directory): ar { $directory = self::resolveDirectory($directory); - return array_filter($files, function ($value) use ($directory) { + return array_filter($files, static function (string $value) use ($directory): bool { return strpos($value, $directory) === 0; }); } @@ -114,7 +114,7 @@ final protected static function matchFiles(array $files, string $pattern): array $pattern = "#{$pattern}#"; } - return array_filter($files, function ($value) use ($pattern) { + return array_filter($files, static function ($value) use ($pattern) { return (bool) preg_match($pattern, basename($value)); }); } @@ -150,7 +150,7 @@ protected function define(): void */ public function get(): array { - $this->files = array_unique($this->files, SORT_STRING); + $this->files = array_unique($this->files); sort($this->files, SORT_STRING); return $this->files; From 5eafc2f20e51c06405b8fa97374fb151f300d8d9 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Wed, 16 Jun 2021 22:30:50 -0500 Subject: [PATCH 23/65] Updated assert to assertArrayNotHasKey per suggestion. --- system/Test/Mock/MockCache.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index b8f05760a3a6..d4c5657c375f 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -336,6 +336,6 @@ public function assertHasValue(string $key, $value = null) */ public function assertMissing(string $key) { - Assert::assertFalse(array_key_exists($key, $this->cache), "The cached item named `{$key}` exists."); + Assert::assertArrayNotHasKey($key, $this->cache, "The cached item named `{$key}` exists."); } } From 463596f4169872949e723252f65e59905d540ac2 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Wed, 16 Jun 2021 22:41:40 -0500 Subject: [PATCH 24/65] Remove extra use statement in MockCache --- system/Test/Mock/MockCache.php | 1 - 1 file changed, 1 deletion(-) diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index b981d03901dc..10187b1cba56 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -15,7 +15,6 @@ use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\Handlers\BaseHandler; use PHPUnit\Framework\Assert; -use Closure; class MockCache extends BaseHandler implements CacheInterface { From cd0d36af5cbd1e65aaba0fa9074973b452ea7911 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Fri, 18 Jun 2021 22:31:49 -0500 Subject: [PATCH 25/65] Replaced code that went missing during merge conflict fixes. --- system/Test/Mock/MockCache.php | 618 +++++++++++++++++---------------- 1 file changed, 317 insertions(+), 301 deletions(-) diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 10187b1cba56..42e84103e823 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -18,305 +18,321 @@ class MockCache extends BaseHandler implements CacheInterface { - /** - * Mock cache storage. - * - * @var array - */ - protected $cache = []; - - /** - * Expiration times. - * - * @var ?int[] - */ - protected $expirations = []; - - /** - * If true, will not cache any data. - * - * @var boolean - */ - protected $bypass = false; - - /** - * Takes care of any handler-specific setup that must be done. - */ - public function initialize() - { - } - - /** - * Attempts to fetch an item from the cache store. - * - * @param string $key Cache item name - * - * @return mixed - */ - public function get(string $key) - { - $key = static::validateKey($key, $this->prefix); - - return $this->cache[$key] ?? null; - } - - /** - * Get an item from the cache, or execute the given Closure and store the result. - * - * @param string $key Cache item name - * @param int $ttl Time to live - * @param Closure $callback Callback return value - * - * @return mixed - */ - public function remember(string $key, int $ttl, Closure $callback) - { - $value = $this->get($key); - - if (! is_null($value)) { - return $value; - } - - $this->save($key, $value = $callback(), $ttl); - - return $value; - } - - /** - * Saves an item to the cache store. - * - * The $raw parameter is only utilized by Mamcache in order to - * allow usage of increment() and decrement(). - * - * @param string $key Cache item name - * @param mixed $value the data to save - * @param int $ttl Time To Live, in seconds (default 60) - * @param bool $raw Whether to store the raw value. - * - * @return bool - */ - public function save(string $key, $value, int $ttl = 60, bool $raw = false) - { - $key = static::validateKey($key, $this->prefix); - - $this->cache[$key] = $value; - $this->expirations[$key] = $ttl > 0 ? time() + $ttl : null; - - return true; - } - - /** - * Deletes a specific item from the cache store. - * - * @param string $key Cache item name - * - * @return bool - */ - public function delete(string $key) - { - $key = static::validateKey($key, $this->prefix); - - if (! isset($this->cache[$key])) { - return false; - } - - unset($this->cache[$key], $this->expirations[$key]); - - return true; - } - - /** - * Deletes items from the cache store matching a given pattern. - * - * @param string $pattern Cache items glob-style pattern - * - * @return int - */ - public function deleteMatching(string $pattern) - { - $count = 0; - - foreach (array_keys($this->cache) as $key) { - if (fnmatch($pattern, $key)) { - $count++; - unset($this->cache[$key], $this->expirations[$key]); - } - } - - return $count; - } - - /** - * Performs atomic incrementation of a raw stored value. - * - * @param string $key Cache ID - * @param int $offset Step/value to increase by - * - * @return bool - */ - public function increment(string $key, int $offset = 1) - { - $key = static::validateKey($key, $this->prefix); - $data = $this->cache[$key] ?: null; - - if (empty($data)) { - $data = 0; - } elseif (! is_int($data)) { - return false; - } - - return $this->save($key, $data + $offset); - } - - /** - * Performs atomic decrementation of a raw stored value. - * - * @param string $key Cache ID - * @param int $offset Step/value to increase by - * - * @return bool - */ - public function decrement(string $key, int $offset = 1) - { - $key = static::validateKey($key, $this->prefix); - - $data = $this->cache[$key] ?: null; - - if (empty($data)) { - $data = 0; - } elseif (! is_int($data)) { - return false; - } - - return $this->save($key, $data - $offset); - } - - /** - * Will delete all items in the entire cache. - * - * @return bool - */ - public function clean() - { - $this->cache = []; - $this->expirations = []; - - return true; - } - - /** - * Returns information on the entire cache. - * - * The information returned and the structure of the data - * varies depending on the handler. - * - * @return string[] Keys currently present in the store - */ - public function getCacheInfo() - { - return array_keys($this->cache); - } - - /** - * Returns detailed information about the specific item in the cache. - * - * @param string $key Cache item name. - * - * @return array|null - * Returns null if the item does not exist, otherwise array - * with at least the 'expire' key for absolute epoch expiry (or null). - */ - public function getMetaData(string $key) - { - // Misses return null - if (! array_key_exists($key, $this->expirations)) { - return null; - } - - // Count expired items as a miss - if (is_int($this->expirations[$key]) && $this->expirations[$key] > time()) { - return null; - } - - return [ - 'expire' => $this->expirations[$key], - ]; - } - - /** - * Determines if the driver is supported on this system. - * - * @return bool - */ - public function isSupported(): bool - { - return true; - } - - - //-------------------------------------------------------------------- - // Test Helpers - //-------------------------------------------------------------------- - - /** - * Instructs the class to ignore all - * requests to cache an item, and always "miss" - * when checked for existing data. - * - * @return $this - */ - public function bypass(bool $bypass = true) - { - $this->clean(); - - $this->bypass = $bypass; - - return $this; - } - - //-------------------------------------------------------------------- - // Additional Assertions - //-------------------------------------------------------------------- - - /** - * Asserts that the cache has an item named $key. - * The value is not checked since storing false or null - * values is valid. - * - * @param string $key - */ - public function assertHas(string $key) - { - Assert::assertNotNull($this->get($key), "The cache does not have an item named: `{$key}`"); - } - - /** - * Asserts that the cache has an item named $key with a value matching $value. - * - * @param string $key - * @param null $value - */ - public function assertHasValue(string $key, $value = null) - { - $item = $this->get($key); - - // Let assertHas handle throwing the error for consistency - // if the key is not found - if (empty($item)) - { - $this->assertHas($key); - } - - Assert::assertEquals($value, $this->get($key), "The cached item `{$key}` does not equal match expectation. Found: " . print_r($value, true)); - } - - /** - * Asserts that the cache does NOT have an item named $key. - * - * @param string $key - */ - public function assertMissing(string $key) - { - Assert::assertArrayNotHasKey($key, $this->cache, "The cached item named `{$key}` exists."); - } + /** + * Mock cache storage. + * + * @var array + */ + protected $cache = []; + + /** + * Expiration times. + * + * @var ?int[] + */ + protected $expirations = []; + + /** + * If true, will not cache any data. + * + * @var boolean + */ + protected $bypass = false; + + /** + * Takes care of any handler-specific setup that must be done. + */ + public function initialize() + { + } + + /** + * Attempts to fetch an item from the cache store. + * + * @param string $key Cache item name + * + * @return mixed + */ + public function get(string $key) + { + $key = static::validateKey($key, $this->prefix); + + return $this->cache[$key] ?? null; + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * @param string $key Cache item name + * @param integer $ttl Time to live + * @param Closure $callback Callback return value + * + * @return mixed + */ + public function remember(string $key, int $ttl, Closure $callback) + { + $value = $this->get($key); + + if (! is_null($value)) + { + return $value; + } + + $this->save($key, $value = $callback(), $ttl); + + return $value; + } + + /** + * Saves an item to the cache store. + * + * The $raw parameter is only utilized by Mamcache in order to + * allow usage of increment() and decrement(). + * + * @param string $key Cache item name + * @param mixed $value the data to save + * @param integer $ttl Time To Live, in seconds (default 60) + * @param boolean $raw Whether to store the raw value. + * + * @return boolean + */ + public function save(string $key, $value, int $ttl = 60, bool $raw = false) + { + if ($this->bypass) + { + return true; + } + + $key = static::validateKey($key, $this->prefix); + + $this->cache[$key] = $value; + $this->expirations[$key] = $ttl > 0 ? time() + $ttl : null; + + return true; + } + + /** + * Deletes a specific item from the cache store. + * + * @param string $key Cache item name + * + * @return boolean + */ + public function delete(string $key) + { + $key = static::validateKey($key, $this->prefix); + + if (! isset($this->cache[$key])) + { + return false; + } + + unset($this->cache[$key], $this->expirations[$key]); + + return true; + } + + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return integer + */ + public function deleteMatching(string $pattern) + { + $count = 0; + + foreach (array_keys($this->cache) as $key) + { + if (fnmatch($pattern, $key)) + { + $count++; + unset($this->cache[$key], $this->expirations[$key]); + } + } + + return $count; + } + + /** + * Performs atomic incrementation of a raw stored value. + * + * @param string $key Cache ID + * @param integer $offset Step/value to increase by + * + * @return boolean + */ + public function increment(string $key, int $offset = 1) + { + $key = static::validateKey($key, $this->prefix); + $data = $this->cache[$key] ?: null; + + if (empty($data)) + { + $data = 0; + } + elseif (! is_int($data)) + { + return false; + } + + return $this->save($key, $data + $offset); + } + + /** + * Performs atomic decrementation of a raw stored value. + * + * @param string $key Cache ID + * @param integer $offset Step/value to increase by + * + * @return boolean + */ + public function decrement(string $key, int $offset = 1) + { + $key = static::validateKey($key, $this->prefix); + + $data = $this->cache[$key] ?: null; + + if (empty($data)) + { + $data = 0; + } + elseif (! is_int($data)) + { + return false; + } + + return $this->save($key, $data - $offset); + } + + /** + * Will delete all items in the entire cache. + * + * @return boolean + */ + public function clean() + { + $this->cache = []; + $this->expirations = []; + + return true; + } + + /** + * Returns information on the entire cache. + * + * The information returned and the structure of the data + * varies depending on the handler. + * + * @return string[] Keys currently present in the store + */ + public function getCacheInfo() + { + return array_keys($this->cache); + } + + /** + * Returns detailed information about the specific item in the cache. + * + * @param string $key Cache item name. + * + * @return array|null + * Returns null if the item does not exist, otherwise array + * with at least the 'expire' key for absolute epoch expiry (or null). + */ + public function getMetaData(string $key) + { + // Misses return null + if (! array_key_exists($key, $this->expirations)) + { + return null; + } + + // Count expired items as a miss + if (is_int($this->expirations[$key]) && $this->expirations[$key] > time()) + { + return null; + } + + return [ + 'expire' => $this->expirations[$key], + ]; + } + + /** + * Determines if the driver is supported on this system. + * + * @return boolean + */ + public function isSupported(): bool + { + return true; + } + + //-------------------------------------------------------------------- + // Test Helpers + //-------------------------------------------------------------------- + + /** + * Instructs the class to ignore all + * requests to cache an item, and always "miss" + * when checked for existing data. + * + * @return $this + */ + public function bypass(bool $bypass = true) + { + $this->clean(); + + $this->bypass = $bypass; + + return $this; + } + + //-------------------------------------------------------------------- + // Additional Assertions + //-------------------------------------------------------------------- + + /** + * Asserts that the cache has an item named $key. + * The value is not checked since storing false or null + * values is valid. + * + * @param string $key + */ + public function assertHas(string $key) + { + Assert::assertNotNull($this->get($key), "The cache does not have an item named: `{$key}`"); + } + + /** + * Asserts that the cache has an item named $key with a value matching $value. + * + * @param string $key + * @param null $value + */ + public function assertHasValue(string $key, $value = null) + { + $item = $this->get($key); + + // Let assertHas handle throwing the error for consistency + // if the key is not found + if (empty($item)) + { + $this->assertHas($key); + } + + Assert::assertEquals($value, $this->get($key), "The cached item `{$key}` does not equal match expectation. Found: " . print_r($value, true)); + } + + /** + * Asserts that the cache does NOT have an item named $key. + * + * @param string $key + */ + public function assertMissing(string $key) + { + Assert::assertArrayNotHasKey($key, $this->cache, "The cached item named `{$key}` exists."); + } } From 8d09d10af1c7f0c643109b99901f72e0ea595e7c Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" <51850998+paulbalandan@users.noreply.github.com> Date: Tue, 22 Jun 2021 00:27:38 +0800 Subject: [PATCH 26/65] Make CLI detection as interface independent as possible --- system/Common.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/system/Common.php b/system/Common.php index 12b75f12dd02..fedd20442941 100644 --- a/system/Common.php +++ b/system/Common.php @@ -683,22 +683,14 @@ function helper($filenames) * * @return bool * - * @codeCoverageIgnore Cannot be tested fully as PHPUnit always run in CLI + * @codeCoverageIgnore Cannot be tested fully as PHPUnit always run in php-cli */ function is_cli(): bool { - if (PHP_SAPI === 'cli') { - return true; - } - if (defined('STDIN')) { return true; } - if (stristr(PHP_SAPI, 'cgi') && getenv('TERM')) { - return true; - } - if (! isset($_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_USER_AGENT']) && isset($_SERVER['argv']) && count($_SERVER['argv']) > 0) { return true; } From 929e5c4f4259d5940b20a72db421eb0b2d4bf451 Mon Sep 17 00:00:00 2001 From: sclubricants Date: Mon, 21 Jun 2021 09:31:14 -0700 Subject: [PATCH 27/65] Added MySQLi resultMode and updated user guide --- system/Database/MySQLi/Connection.php | 15 ++++++++++- user_guide_src/source/database/results.rst | 30 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 83795a5dbb9d..e8acade01f61 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -64,6 +64,19 @@ class Connection extends BaseConnection //-------------------------------------------------------------------- + /** + * MySQLi constant + * + * For unbuffered queries use `MYSQLI_USE_RESULT`. + * + * Default mode for buffered queries uses `MYSQLI_STORE_RESULT`. + * + * @var int + */ + public $resultMode = MYSQLI_STORE_RESULT; + + //-------------------------------------------------------------------- + /** * Connect to the database. * @@ -295,7 +308,7 @@ public function execute(string $sql) } try { - return $this->connID->query($this->prepQuery($sql)); + return $this->connID->query($this->prepQuery($sql), $this->resultMode); } catch (mysqli_sql_exception $e) { log_message('error', $e->getMessage()); diff --git a/user_guide_src/source/database/results.rst b/user_guide_src/source/database/results.rst index 79445da9e529..108b146c351b 100644 --- a/user_guide_src/source/database/results.rst +++ b/user_guide_src/source/database/results.rst @@ -166,6 +166,36 @@ it returns the current row and moves the internal data pointer ahead. echo $row->body; } +For use with MySQLi you may set MySQLi's result mode to +``MYSQLI_USE_RESULT`` for maximum memory savings. Use of this is not +generally recommended but it can be beneficial in some circumstances +such as writing large queries to csv. If you change the result mode +be aware of the tradeoffs associated with it. + +:: + + $db->resultMode = MYSQLI_USE_RESULT; // for unbuffered results + + $query = $db->query("YOUR QUERY"); + + $file = new \CodeIgniter\Files\File(WRITEPATH.'data.csv'); + + $csv = $file->openFile('w'); + + while ($row = $query->getUnbufferedRow('array')) + { + $csv->fputcsv($row); + } + + $db->resultMode = MYSQLI_STORE_RESULT; // return to default mode + +.. note:: When using ``MYSQLI_USE_RESULT`` all subsequent calls on the same + connection will result in error until all records have been fetched or + a ``freeResult()`` call has been made. The ``getNumRows()`` method will only + return the number of rows based on the current position of the data pointer. + MyISAM tables will remain locked until all the records have been fetched + or a ``freeResult()`` call has been made. + You can optionally pass 'object' (default) or 'array' in order to specify the returned value's type:: From a5d9c9e13fd0a9553260b94f54e3b747c5fce0f5 Mon Sep 17 00:00:00 2001 From: MGatner Date: Wed, 23 Jun 2021 13:47:27 +0000 Subject: [PATCH 28/65] Implement review suggestions --- system/Files/FileCollection.php | 14 +++++++++++++- system/Publisher/Publisher.php | 14 ++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php index 4231c5776642..cc11cf8a7962 100644 --- a/system/Files/FileCollection.php +++ b/system/Files/FileCollection.php @@ -34,6 +34,8 @@ class FileCollection implements Countable, IteratorAggregate */ protected $files = []; + //-------------------------------------------------------------------- + // Support Methods //-------------------------------------------------------------------- /** @@ -104,7 +106,7 @@ final protected static function filterFiles(array $files, string $directory): ar final protected static function matchFiles(array $files, string $pattern): array { // Convert pseudo-regex into their true form - if (@preg_match($pattern, null) === false) // @phpstan-ignore-line + if (@preg_match($pattern, '') === false) { $pattern = str_replace( ['#', '.', '*', '?'], @@ -119,6 +121,8 @@ final protected static function matchFiles(array $files, string $pattern): array }); } + //-------------------------------------------------------------------- + // Class Core //-------------------------------------------------------------------- /** @@ -209,6 +213,8 @@ public function add($paths, bool $recursive = true) return $this; } + //-------------------------------------------------------------------- + // File Handling //-------------------------------------------------------------------- /** @@ -268,6 +274,8 @@ public function removeFile(string $file) return $this->removeFiles([$file]); } + //-------------------------------------------------------------------- + // Directory Handling //-------------------------------------------------------------------- /** @@ -317,6 +325,8 @@ public function addDirectory(string $directory, bool $recursive = false) return $this; } + //-------------------------------------------------------------------- + // Filtering //-------------------------------------------------------------------- /** @@ -366,6 +376,8 @@ public function retainPattern(string $pattern, string $scope = null) } //-------------------------------------------------------------------- + // Interface Methods + //-------------------------------------------------------------------- /** * Returns the current number of files in the collection. diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 1f28bc331baf..ab4484cdea0b 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -87,6 +87,8 @@ class Publisher extends FileCollection */ protected $destination = FCPATH; + //-------------------------------------------------------------------- + // Support Methods //-------------------------------------------------------------------- /** @@ -154,6 +156,8 @@ private static function wipeDirectory(string $directory): void } } + //-------------------------------------------------------------------- + // Class Core //-------------------------------------------------------------------- /** @@ -217,6 +221,8 @@ public function publish(): bool return $this->addPath('/')->merge(true); } + //-------------------------------------------------------------------- + // Property Accessors //-------------------------------------------------------------------- /** @@ -275,6 +281,8 @@ final public function getPublished(): array return $this->published; } + //-------------------------------------------------------------------- + // Additional Handlers //-------------------------------------------------------------------- /** @@ -310,8 +318,6 @@ final public function addPath(string $path, bool $recursive = true) return $this; } - //-------------------------------------------------------------------- - /** * Downloads and stages files from an array of URIs. * @@ -347,6 +353,8 @@ final public function addUri(string $uri) return $this->addFile($file); } + //-------------------------------------------------------------------- + // Write Methods //-------------------------------------------------------------------- /** @@ -361,8 +369,6 @@ final public function wipe() return $this; } - //-------------------------------------------------------------------- - /** * Copies all files into the destination, does not create directory structure. * From 3e4415a5215dcd5527095d67836148d9ff2671ce Mon Sep 17 00:00:00 2001 From: Alex Schmitz Date: Wed, 23 Jun 2021 17:57:44 +0200 Subject: [PATCH 29/65] Sort timeline elements by start and duration. --- system/Debug/Toolbar.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 1acf00e73aaf..a954832b529b 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -245,6 +245,12 @@ protected function collectTimelineData($collectors): array } // Sort it + $sortArray = [ + array_column($data, 'start'), SORT_NUMERIC, SORT_ASC, + array_column($data, 'duration'), SORT_NUMERIC, SORT_DESC, + &$data + ]; + array_multisort(...$sortArray); return $data; } From b09afedf7f9c6f25468ff820ceef966f2332e5a0 Mon Sep 17 00:00:00 2001 From: MGatner Date: Thu, 24 Jun 2021 07:57:49 -0400 Subject: [PATCH 30/65] Update system/Files/FileCollection.php Co-authored-by: John Paul E. Balandan, CPA <51850998+paulbalandan@users.noreply.github.com> --- system/Files/FileCollection.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php index cc11cf8a7962..128d884940d5 100644 --- a/system/Files/FileCollection.php +++ b/system/Files/FileCollection.php @@ -185,10 +185,7 @@ public function set(array $files) */ public function add($paths, bool $recursive = true) { - if (! is_array($paths)) - { - $paths = [$paths]; - } + $paths = (array) $paths; foreach ($paths as $path) { From c9e1f84dc032f7badd337185e945554fd86b7e67 Mon Sep 17 00:00:00 2001 From: MGatner Date: Thu, 24 Jun 2021 07:58:26 -0400 Subject: [PATCH 31/65] Update user_guide_src/source/libraries/files.rst Co-authored-by: John Paul E. Balandan, CPA <51850998+paulbalandan@users.noreply.github.com> --- user_guide_src/source/libraries/files.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user_guide_src/source/libraries/files.rst b/user_guide_src/source/libraries/files.rst index d80d31f03a9d..c128558c56ab 100644 --- a/user_guide_src/source/libraries/files.rst +++ b/user_guide_src/source/libraries/files.rst @@ -161,8 +161,8 @@ predefined collections without having to use their methods. Example:: class ConfigCollection extends \CodeIgniter\Files\FileCollection { - protected function define(): void { - + protected function define(): void + { $this->add(APPPATH . 'Config', true)->retainPattern('*.php'); } } From 9936bb4a3ec9e94ba3a4509d13a603895b7f1a62 Mon Sep 17 00:00:00 2001 From: MGatner Date: Thu, 24 Jun 2021 07:59:38 -0400 Subject: [PATCH 32/65] Update tests/system/Files/FileCollectionTest.php --- tests/system/Files/FileCollectionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/Files/FileCollectionTest.php b/tests/system/Files/FileCollectionTest.php index a650bc464b01..ae2df9178f5f 100644 --- a/tests/system/Files/FileCollectionTest.php +++ b/tests/system/Files/FileCollectionTest.php @@ -118,7 +118,7 @@ public function testConstructorAddsFiles() public function testConstructorCallsDefine() { - $collection = new class([$this->file]) extends FileCollection { + $collection = new class() extends FileCollection { protected function define(): void { From a351963c6f94ec55df83084405eab7ad71a1bb89 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Thu, 24 Jun 2021 21:57:34 +0800 Subject: [PATCH 33/65] Namespace extracted by GeneratorTrait should always end in backslash --- system/CLI/GeneratorTrait.php | 6 +++--- .../system/Commands/CommandGeneratorTest.php | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index 925f1c73a6b5..8c2115a8bc1e 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -229,14 +229,14 @@ protected function qualifyClassName(): string // Trims input, normalize separators, and ensure that all paths are in Pascalcase. $class = ltrim(implode('\\', array_map('pascalize', explode('\\', str_replace('/', '\\', trim($class))))), '\\/'); - // Gets the namespace from input. - $namespace = trim(str_replace('/', '\\', $this->getOption('namespace') ?? APP_NAMESPACE), '\\'); + // Gets the namespace from input. Don't forget the ending backslash! + $namespace = trim(str_replace('/', '\\', $this->getOption('namespace') ?? APP_NAMESPACE), '\\') . '\\'; if (strncmp($class, $namespace, strlen($namespace)) === 0) { return $class; // @codeCoverageIgnore } - return $namespace . '\\' . $this->directory . '\\' . str_replace('/', '\\', $class); + return $namespace . $this->directory . '\\' . str_replace('/', '\\', $class); } /** diff --git a/tests/system/Commands/CommandGeneratorTest.php b/tests/system/Commands/CommandGeneratorTest.php index 3dbfcdb96d70..56639171a28a 100644 --- a/tests/system/Commands/CommandGeneratorTest.php +++ b/tests/system/Commands/CommandGeneratorTest.php @@ -119,4 +119,24 @@ public function testGeneratorPreservesCaseButChangesComponentName(): void $this->assertStringContainsString('File created: ', CITestStreamFilter::$buffer); $this->assertFileExists(APPPATH . 'Controllers/TestModuleController.php'); } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4857 + */ + public function testGeneratorIsNotConfusedWithNamespaceLikeClassNames(): void + { + $time = time(); + $notExists = true; + command('make:migration App_Lesson'); + + // we got 5 chances to prove that the file created went to app/Database/Migrations + foreach (range(0, 4) as $increment) { + $expectedFile = sprintf('%sDatabase/Migrations/%s_AppLesson.php', APPPATH, gmdate('Y-m-d-His', $time + $increment)); + clearstatcache(true, $expectedFile); + + $notExists = $notExists && ! is_file($expectedFile); + } + + $this->assertFalse($notExists, 'Creating migration file for class "AppLesson" did not go to "app/Database/Migrations"'); + } } From 639cf77d96073c53f4a63fd5cd8b341970ee7eec Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 25 Jun 2021 19:10:40 +0200 Subject: [PATCH 34/65] Fix bug with respecting TTL in Predis and File cache --- system/Cache/Handlers/FileHandler.php | 2 +- system/Cache/Handlers/PredisHandler.php | 4 +++- tests/system/Cache/Handlers/FileHandlerTest.php | 12 ++++++++++++ .../Cache/Handlers/MemcachedHandlerTest.php | 12 ++++++++++++ .../system/Cache/Handlers/PredisHandlerTest.php | 16 ++++++++++++++-- tests/system/Cache/Handlers/RedisHandlerTest.php | 12 ++++++++++++ 6 files changed, 54 insertions(+), 4 deletions(-) diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index 9ee4c6691dc0..a3bf2c15d242 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -278,7 +278,7 @@ public function getMetaData(string $key) } return [ - 'expire' => $data['time'] + $data['ttl'], + 'expire' => $data['ttl'] > 0 ? $data['time'] + $data['ttl'] : null, 'mtime' => filemtime($this->path . $key), 'data' => $data['data'], ]; diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 8fb7ca7682d0..83a20db8626d 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -158,7 +158,9 @@ public function save(string $key, $value, int $ttl = 60) return false; } - $this->redis->expireat($key, time() + $ttl); + if ($ttl) { + $this->redis->expireat($key, time() + $ttl); + } return true; } diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index bb998f930295..6b0f32f13f0f 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -144,6 +144,18 @@ public function testSaveExcessiveKeyLength() unlink($file); } + public function testSavePermanent() + { + $this->assertTrue($this->fileHandler->save(self::$key1, 'value', 0)); + $metaData = $this->fileHandler->getMetaData(self::$key1); + + $this->assertSame(null, $metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->fileHandler->delete(self::$key1)); + } + public function testDelete() { $this->fileHandler->save(self::$key1, 'value'); diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index ba6571329d67..4860535a0bb0 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -90,6 +90,18 @@ public function testSave() $this->assertTrue($this->memcachedHandler->save(self::$key1, 'value')); } + public function testSavePermanent() + { + $this->assertTrue($this->memcachedHandler->save(self::$key1, 'value', 0)); + $metaData = $this->memcachedHandler->getMetaData(self::$key1); + + $this->assertSame(null, $metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->memcachedHandler->delete(self::$key1)); + } + public function testDelete() { $this->memcachedHandler->save(self::$key1, 'value'); diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 837a31cccf7c..07a29d9c4efc 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -50,10 +50,10 @@ public function testNew() public function testDestruct() { - $this->PredisHandler = new PRedisHandler($this->config); + $this->PredisHandler = new PredisHandler($this->config); $this->PredisHandler->initialize(); - $this->assertInstanceOf(PRedisHandler::class, $this->PredisHandler); + $this->assertInstanceOf(PredisHandler::class, $this->PredisHandler); } /** @@ -97,6 +97,18 @@ public function testSave() $this->assertTrue($this->PredisHandler->save(self::$key1, 'value')); } + public function testSavePermanent() + { + $this->assertTrue($this->PredisHandler->save(self::$key1, 'value', 0)); + $metaData = $this->PredisHandler->getMetaData(self::$key1); + + $this->assertSame(null, $metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->PredisHandler->delete(self::$key1)); + } + public function testDelete() { $this->PredisHandler->save(self::$key1, 'value'); diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index 6f0dbecfc8e0..c07c703fa782 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -97,6 +97,18 @@ public function testSave() $this->assertTrue($this->redisHandler->save(self::$key1, 'value')); } + public function testSavePermanent() + { + $this->assertTrue($this->redisHandler->save(self::$key1, 'value', 0)); + $metaData = $this->redisHandler->getMetaData(self::$key1); + + $this->assertSame(null, $metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - time()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->redisHandler->delete(self::$key1)); + } + public function testDelete() { $this->redisHandler->save(self::$key1, 'value'); From 5a70162102caff68d1b888959bf220e9ae619fc1 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 26 Jun 2021 11:00:32 +0200 Subject: [PATCH 35/65] Fix database session bug with timestamp in MySQLi --- system/Session/Handlers/DatabaseHandler.php | 11 +- .../20160428212500_Create_test_tables.php | 29 ++++ .../_support/Database/Seeds/CITestSeeder.php | 18 +++ tests/system/Database/Live/MetadataTest.php | 4 + .../Session/Handlers/DatabaseHandlerTest.php | 128 ++++++++++++++++++ 5 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 tests/system/Session/Handlers/DatabaseHandlerTest.php diff --git a/system/Session/Handlers/DatabaseHandler.php b/system/Session/Handlers/DatabaseHandler.php index 647f73eb0da0..69c76ff3df73 100644 --- a/system/Session/Handlers/DatabaseHandler.php +++ b/system/Session/Handlers/DatabaseHandler.php @@ -198,11 +198,10 @@ public function write($sessionID, $sessionData): bool $insertData = [ 'id' => $sessionID, 'ip_address' => $this->ipAddress, - 'timestamp' => 'now()', 'data' => $this->platform === 'postgre' ? '\x' . bin2hex($sessionData) : $sessionData, ]; - if (! $this->db->table($this->table)->insert($insertData)) { + if (! $this->db->table($this->table)->set('timestamp', 'now()', false)->insert($insertData)) { return $this->fail(); } @@ -218,15 +217,13 @@ public function write($sessionID, $sessionData): bool $builder = $builder->where('ip_address', $this->ipAddress); } - $updateData = [ - 'timestamp' => 'now()', - ]; + $updateData = []; if ($this->fingerprint !== md5($sessionData)) { $updateData['data'] = ($this->platform === 'postgre') ? '\x' . bin2hex($sessionData) : $sessionData; } - if (! $builder->update($updateData)) { + if (! $builder->set('timestamp', 'now()', false)->update($updateData)) { return $this->fail(); } @@ -299,7 +296,7 @@ public function gc($maxlifetime): bool $separator = $this->platform === 'postgre' ? '\'' : ' '; $interval = implode($separator, ['', "{$maxlifetime} second", '']); - return $this->db->table($this->table)->delete("timestamp < now() - INTERVAL {$interval}") ? true : $this->fail(); + return $this->db->table($this->table)->where("timestamp <", "now() - INTERVAL {$interval}", false)->delete() ? true : $this->fail(); } //-------------------------------------------------------------------- diff --git a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php index 7fdfbb0c3c34..629550cbfbdd 100644 --- a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php +++ b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php @@ -118,6 +118,31 @@ public function up() 'ip' => ['type' => 'VARCHAR', 'constraint' => 100], 'ip2' => ['type' => 'VARCHAR', 'constraint' => 100], ])->createTable('ip_table', true); + + // Database session table + if ($this->db->DBDriver === 'MySQLi') { + $this->forge->addField([ + 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => false], + 'timestamp timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL', + 'data' => ['type' => 'BLOB', 'null' => false], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('timestamp'); + $this->forge->createTable('ci_sessions', true); + } + + if ($this->db->DBDriver === 'Postgre') { + $this->forge->addField([ + 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'ip_address inet NOT NULL', + 'timestamp timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL', + "data bytea DEFAULT '' NOT NULL", + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('timestamp'); + $this->forge->createTable('ci_sessions', true); + } } //-------------------------------------------------------------------- @@ -133,5 +158,9 @@ public function down() $this->forge->dropTable('stringifypkey', true); $this->forge->dropTable('without_auto_increment', true); $this->forge->dropTable('ip_table', true); + + if (in_array($this->db->DBDriver, ['MySQLi', 'Postgre'])) { + $this->forge->dropTable('ci_sessions', true); + } } } diff --git a/tests/_support/Database/Seeds/CITestSeeder.php b/tests/_support/Database/Seeds/CITestSeeder.php index 45633244ac51..e633543414f7 100644 --- a/tests/_support/Database/Seeds/CITestSeeder.php +++ b/tests/_support/Database/Seeds/CITestSeeder.php @@ -137,6 +137,24 @@ public function run() ); } + if ($this->db->DBDriver === 'MySQLi') { + $data['ci_sessions'][] = [ + 'id' => '1f5o06b43phsnnf8if6bo33b635e4p2o', + 'ip_address' => '127.0.0.1', + 'timestamp' => '2021-06-25 21:54:14', + 'data' => '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";', + ]; + } + + if ($this->db->DBDriver === 'Postgre') { + $data['ci_sessions'][] = [ + 'id' => '1f5o06b43phsnnf8if6bo33b635e4p2o', + 'ip_address' => '127.0.0.1', + 'timestamp' => '2021-06-25 21:54:14.991403+02', + 'data' => '\x' . bin2hex('__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'), + ]; + } + foreach ($data as $table => $dummy_data) { $this->db->table($table)->truncate(); diff --git a/tests/system/Database/Live/MetadataTest.php b/tests/system/Database/Live/MetadataTest.php index 9480506a8780..4d5a08e235cb 100644 --- a/tests/system/Database/Live/MetadataTest.php +++ b/tests/system/Database/Live/MetadataTest.php @@ -41,6 +41,10 @@ protected function setUp(): void $prefix . 'without_auto_increment', $prefix . 'ip_table', ]; + + if (in_array($this->db->DBDriver, ['MySQLi', 'Postgre'], true)) { + $this->expectedTables[] = $prefix . 'ci_sessions'; + } } public function testListTables() diff --git a/tests/system/Session/Handlers/DatabaseHandlerTest.php b/tests/system/Session/Handlers/DatabaseHandlerTest.php new file mode 100644 index 000000000000..17a396a9b769 --- /dev/null +++ b/tests/system/Session/Handlers/DatabaseHandlerTest.php @@ -0,0 +1,128 @@ +tests['DBDriver'], ['MySQLi', 'Postgre'], true)) { + $this->markTestSkipped('Database Session Handler requires database driver to be MySQLi or Postgre'); + } + } + + protected function getInstance($options = []) + { + $defaults = [ + 'sessionDriver' => 'CodeIgniter\Session\Handlers\DatabaseHandler', + 'sessionCookieName' => 'ci_session', + 'sessionExpiration' => 7200, + 'sessionSavePath' => 'ci_sessions', + 'sessionMatchIP' => false, + 'sessionTimeToUpdate' => 300, + 'sessionRegenerateDestroy' => false, + 'cookieDomain' => '', + 'cookiePrefix' => '', + 'cookiePath' => '/', + 'cookieSecure' => false, + 'cookieSameSite' => 'Lax', + ]; + + $config = array_merge($defaults, $options); + $appConfig = new AppConfig(); + + foreach ($config as $key => $c) { + $appConfig->$key = $c; + } + + return new DatabaseHandler($appConfig, '127.0.0.1'); + } + + public function testOpen() + { + $handler = $this->getInstance(); + $this->assertTrue($handler->open('ci_sessions', 'ci_session')); + } + + public function testReadSuccess() + { + $handler = $this->getInstance(); + $expected = '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'; + $this->assertSame($expected, $handler->read('1f5o06b43phsnnf8if6bo33b635e4p2o')); + + $this->assertTrue($this->getPrivateProperty($handler, 'rowExists')); + $this->assertSame('1483201a66afd2bd671e4a67dc6ecf24', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testReadFailure() + { + $handler = $this->getInstance(); + $this->assertSame('', $handler->read('123456b43phsnnf8if6bo33b635e4321')); + + $this->assertFalse($this->getPrivateProperty($handler, 'rowExists')); + $this->assertSame('d41d8cd98f00b204e9800998ecf8427e', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testWriteInsert() + { + $handler = $this->getInstance(); + + $this->setPrivateProperty($handler, 'lock', true); + + $data = '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'; + $this->assertTrue($handler->write('555556b43phsnnf8if6bo33b635e4444', $data)); + + $this->setPrivateProperty($handler, 'lock', false); + + $row = $this->db->table('ci_sessions') + ->getWhere(['id' => '555556b43phsnnf8if6bo33b635e4444']) + ->getRow(); + + $this->assertGreaterThan(time() - 100, strtotime($row->timestamp)); + $this->assertSame('1483201a66afd2bd671e4a67dc6ecf24', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testWriteUpdate() + { + $handler = $this->getInstance(); + + $this->setPrivateProperty($handler, 'sessionID', '1f5o06b43phsnnf8if6bo33b635e4p2o'); + $this->setPrivateProperty($handler, 'rowExists', true); + + $lockSession = $this->getPrivateMethodInvoker($handler, 'lockSession'); + $lockSession('1f5o06b43phsnnf8if6bo33b635e4p2o'); + + $data = '__ci_last_regenerate|i:1624650854;_ci_previous_url|s:40:\"http://localhost/index.php/home/index\";'; + $this->assertTrue($handler->write('1f5o06b43phsnnf8if6bo33b635e4p2o', $data)); + + $releaseLock = $this->getPrivateMethodInvoker($handler, 'releaseLock'); + $releaseLock(); + + $row = $this->db->table('ci_sessions') + ->getWhere(['id' => '1f5o06b43phsnnf8if6bo33b635e4p2o']) + ->getRow(); + + $this->assertGreaterThan(time() - 100, strtotime($row->timestamp)); + $this->assertSame('1483201a66afd2bd671e4a67dc6ecf24', $this->getPrivateProperty($handler, 'fingerprint')); + } + + public function testGC() + { + $handler = $this->getInstance(); + $this->assertTrue($handler->gc(3600)); + } +} From cd4ad9d836b08e85b0dfa68a417026d85aef10e2 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 26 Jun 2021 11:14:18 +0200 Subject: [PATCH 36/65] Fix for generating session table structure via migrations --- .../Generators/MigrationGenerator.php | 9 +++---- .../Generators/Views/migration.tpl.php | 24 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php index 23cdf7f62725..96c12d17f0a6 100644 --- a/system/Commands/Generators/MigrationGenerator.php +++ b/system/Commands/Generators/MigrationGenerator.php @@ -107,10 +107,11 @@ protected function prepare(string $class): string $table = $this->getOption('table'); $DBGroup = $this->getOption('dbgroup'); - $data['session'] = true; - $data['table'] = is_string($table) ? $table : 'ci_sessions'; - $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default'; - $data['matchIP'] = config('App')->sessionMatchIP; + $data['session'] = true; + $data['table'] = is_string($table) ? $table : 'ci_sessions'; + $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default'; + $data['DBDriver'] = config('Database')->{$data['DBGroup']}['DBDriver']; + $data['matchIP'] = config('App')->sessionMatchIP; } return $this->parseTemplate($class, [], [], $data); diff --git a/system/Commands/Generators/Views/migration.tpl.php b/system/Commands/Generators/Views/migration.tpl.php index 436ee85e04cc..321895e670c2 100644 --- a/system/Commands/Generators/Views/migration.tpl.php +++ b/system/Commands/Generators/Views/migration.tpl.php @@ -12,17 +12,23 @@ class {class} extends Migration public function up() { $this->forge->addField([ - 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => false], - 'timestamp' => ['type' => 'INT', 'unsigned' => true, 'null' => false, 'default' => 0], - 'data' => ['type' => 'TEXT', 'null' => false, 'default' => ''], + 'timestamp timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL', + 'data' => ['type' => 'BLOB', 'null' => false], + + 'ip_address inet NOT NULL', + 'timestamp timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL', + "data bytea DEFAULT '' NOT NULL", + ]); - - $this->forge->addKey(['id', 'ip_address'], true); - - $this->forge->addKey('id', true); - - $this->forge->addKey('timestamp'); + + $this->forge->addKey(['id', 'ip_address'], true); + + $this->forge->addKey('id', true); + + $this->forge->addKey('timestamp'); $this->forge->createTable('', true); } From bf30dabc228dd31f254e7f6ef3b068963a133c7d Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 27 Jun 2021 07:42:27 +0200 Subject: [PATCH 37/65] Update docblock for redirect function [ci skip] --- system/Common.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/system/Common.php b/system/Common.php index fedd20442941..77b8adcffbb0 100644 --- a/system/Common.php +++ b/system/Common.php @@ -866,9 +866,7 @@ function old(string $key, $default = null, $escape = 'html') /** * Convenience method that works with the current global $request and * $router instances to redirect using named/reverse-routed routes - * to determine the URL to go to. If nothing is found, will treat - * as a traditional redirect and pass the string in, letting - * $response->redirect() determine the correct method and code. + * to determine the URL to go to. * * If more control is needed, you must use $response->redirect explicitly. * From 84646f52aebcf409264669df0c55cfa863929a20 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 27 Jun 2021 08:30:52 +0200 Subject: [PATCH 38/65] Fix mysqli ssl connection - certificate is not required to establish a secure connection --- system/Database/MySQLi/Connection.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index e8acade01f61..487c0d876650 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -160,12 +160,13 @@ public function connect(bool $persistent = false) } } - $clientFlags += MYSQLI_CLIENT_SSL; $this->mysqli->ssl_set( $ssl['key'] ?? null, $ssl['cert'] ?? null, $ssl['ca'] ?? null, $ssl['capath'] ?? null, $ssl['cipher'] ?? null ); } + + $clientFlags += MYSQLI_CLIENT_SSL; } try { From a988368467e5e1354584a86c54da4ec769ad90f5 Mon Sep 17 00:00:00 2001 From: Alex Schmitz Date: Mon, 28 Jun 2021 09:02:59 +0200 Subject: [PATCH 39/65] Structure debug toolbar timeline with collapsible elements. --- system/Debug/Toolbar.php | 102 ++++++++++++++++++++++--- system/Debug/Toolbar/Views/toolbar.css | 25 +++++- system/Debug/Toolbar/Views/toolbar.js | 20 +++++ 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index a954832b529b..e53a7cfdf14d 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -194,17 +194,47 @@ public function run(float $startTime, float $totalTime, RequestInterface $reques * @return string */ protected function renderTimeline(array $collectors, float $startTime, int $segmentCount, int $segmentDuration, array &$styles): string + { + $rows = $this->collectTimelineData($collectors); + $output = ''; + $styleCount = 0; + + // Use recursive render function + return $this->renderTimelineRecursive($rows, $startTime, $segmentCount, $segmentDuration, $styles, $styleCount); + } + + /** + * Recursively renders timeline elements and their children. + * + * @param array $rows + * @param float $startTime + * @param int $segmentCount + * @param int $segmentDuration + * @param array $styles + * @param int $styleCount + * @param int $level + * @param bool $isChild + * + * @return string + */ + protected function renderTimelineRecursive(array $rows, float $startTime, int $segmentCount, int $segmentDuration, array &$styles, int &$styleCount, int $level = 0, bool $isChild = false): string { $displayTime = $segmentCount * $segmentDuration; - $rows = $this->collectTimelineData($collectors); - $output = ''; - $styleCount = 0; + + $output = ''; foreach ($rows as $row) { - $output .= ''; - $output .= "{$row['name']}"; - $output .= "{$row['component']}"; - $output .= "" . number_format($row['duration'] * 1000, 2) . ' ms'; + $hasChildren = isset($row['children']) && !empty($row['children']); + $open = $row['name'] === 'Controller'; + + if ($hasChildren) { + $output .= ''; + } else { + $output .= ''; + } + $output .= '' . ($hasChildren ? '' : '') . $row['name'] . ''; + $output .= '' . $row['component'] . ''; + $output .= '' . number_format($row['duration'] * 1000, 2) . ' ms'; $output .= ""; $offset = ((((float) $row['start'] - $startTime) * 1000) / $displayTime) * 100; @@ -217,6 +247,19 @@ protected function renderTimeline(array $collectors, float $startTime, int $segm $output .= ''; $styleCount++; + + // Add children if any + if ($hasChildren) { + $output .= ''; + $output .= ''; + $output .= ''; + $output .= ''; + $output .= $this->renderTimelineRecursive($row['children'], $startTime, $segmentCount, $segmentDuration, $styles, $styleCount, $level + 1, true); + $output .= ''; + $output .= '
'; + $output .= ''; + $output .= ''; + } } return $output; @@ -246,15 +289,54 @@ protected function collectTimelineData($collectors): array // Sort it $sortArray = [ - array_column($data, 'start'), SORT_NUMERIC, SORT_ASC, - array_column($data, 'duration'), SORT_NUMERIC, SORT_DESC, - &$data + array_column($data, 'start'), SORT_NUMERIC, SORT_ASC, + array_column($data, 'duration'), SORT_NUMERIC, SORT_DESC, + &$data, ]; array_multisort(...$sortArray); + // Add end time to each element + array_walk($data, static function(&$row) { + $row['end'] = $row['start'] + $row['duration']; + }); + + // Group it + $data = $this->structureTimelineData($data); + return $data; } + /** + * Arranges the already sorted timeline data into a parent => child structure. + * + * @param array $elements + * + * @return array + */ + protected function structureTimelineData(array $elements): array + { + // We define ourselves as the first element of the array + $element = array_shift($elements); + + // If we have children behind us, collect and attach them to us + while (!empty($elements) && $elements[array_key_first($elements)]['end'] <= $element['end']) { + $element['children'][] = array_shift($elements); + } + + // Make sure our children know whether they have children, too + if (isset($element['children'])) { + $element['children'] = $this->structureTimelineData($element['children']); + } + + // If we have no younger siblings, we can return + if (empty($elements)) { + return [$element]; + } + + // Make sure our younger siblings know their relatives, too + return array_merge([$element], $this->structureTimelineData($elements)); + } + //-------------------------------------------------------------------- /** diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index f881dd18d6ec..bc8b4ac44ef7 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -186,6 +186,22 @@ #debug-bar .timeline { margin-left: 0; width: 100%; } + #debug-bar .timeline tr.timeline-parent { + cursor: pointer; } + #debug-bar .timeline tr.timeline-parent td:first-child nav { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; + background-position: 0 25%; + display: inline-block; + height: 15px; + width: 15px; + margin-right: 3px; + vertical-align: middle; } + #debug-bar .timeline tr.timeline-parent.timeline-parent-open td:first-child nav { + background-position: 0 75%; } + #debug-bar .timeline tr.timeline-parent.timeline-parent-open { + background-color: #DFDFDF; } + #debug-bar .timeline tr.child-row:hover { + background: transparent; } #debug-bar .timeline th { border-left: 1px solid; font-size: 12px; @@ -200,7 +216,14 @@ padding: 5px; position: relative; } #debug-bar .timeline td:first-child { - border-left: 0; } + border-left: 0; + max-width: none; } + #debug-bar .timeline td.child-container { + padding: 0px; } + #debug-bar .timeline td.child-container .timeline{ + margin: 0px; } + #debug-bar .timeline td.child-container td:first-child:not(.child-container){ + padding-left: calc(5px + 10px * var(--level)); } #debug-bar .timeline .timer { border-radius: 4px; -moz-border-radius: 4px; diff --git a/system/Debug/Toolbar/Views/toolbar.js b/system/Debug/Toolbar/Views/toolbar.js index ca38f9b47bb5..fd7292c96765 100644 --- a/system/Debug/Toolbar/Views/toolbar.js +++ b/system/Debug/Toolbar/Views/toolbar.js @@ -153,6 +153,26 @@ var ciDebugBar = { } }, + /** + * Toggle display of timeline child elements + * + * @param obj + */ + toggleChildRows : function (obj) { + if (typeof obj == 'string') + { + par = document.getElementById(obj + '_parent') + obj = document.getElementById(obj + '_children'); + } + + if (par && obj) + { + obj.style.display = obj.style.display == 'none' ? '' : 'none'; + par.classList.toggle('timeline-parent-open'); + } + }, + + //-------------------------------------------------------------------- /** From 5564f9d875f9d971055f6939b35fbe2d9b5838eb Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Tue, 29 Jun 2021 23:28:18 -0500 Subject: [PATCH 40/65] Updated Query Build custom string option for where to remove make it clear the values do not get escaped. --- user_guide_src/source/database/queries.rst | 1 + user_guide_src/source/database/query_builder.rst | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/user_guide_src/source/database/queries.rst b/user_guide_src/source/database/queries.rst index 93a54f01d861..3b4062679af2 100644 --- a/user_guide_src/source/database/queries.rst +++ b/user_guide_src/source/database/queries.rst @@ -15,6 +15,7 @@ Regular Queries To submit a query, use the **query** function:: + $db = db_connect(); $db->query('YOUR QUERY HERE'); The ``query()`` function returns a database result **object** when "read" diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 62e3aa29b0c9..add336c51081 100755 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -245,7 +245,10 @@ This function enables you to set **WHERE** clauses using one of four methods: .. note:: All values passed to this function are escaped automatically, - producing safer queries. + producing safer queries, except when using a custom string. + +.. note:: ``$builder->where()`` accepts an optional third parameter. If you set it to + ``false``, CodeIgniter will not try to protect your field or table names. #. **Simple key/value method:** @@ -295,15 +298,18 @@ methods: #. **Custom string:** You can write your own clauses manually:: + $where = "name='Joe' AND status='boss' OR status='active'"; $builder->where($where); - ``$builder->where()`` accepts an optional third parameter. If you set it to - ``false``, CodeIgniter will not try to protect your field or table names. + If you are using user-supplied data within the string, you MUST escape the + data manually. Failure to do so could result in SQL injections. +:: - :: + $name = $builder->db->escape('Joe'); + $where = "name={$name} AND status='boss' OR status='active'"; + $builder->where($where); - $builder->where('MATCH (field) AGAINST ("value")', null, false); #. **Subqueries:** You can use an anonymous function to create a subquery. From cfed0b1ccf6d99a0c727a82eb4374a45ae3498c6 Mon Sep 17 00:00:00 2001 From: Ferenc Date: Fri, 2 Jul 2021 19:13:54 +0100 Subject: [PATCH 41/65] Finalize SQLSRV schema support for 4.2 --- system/Database/SQLSRV/Builder.php | 115 +++++++++++++++++- system/Database/SQLSRV/Connection.php | 9 +- system/Database/SQLSRV/Forge.php | 104 +++++++++++++--- system/Database/SQLSRV/Utils.php | 9 ++ .../Database/Live/PreparedQueryTest.php | 6 +- .../Migrations/MigrationRunnerTest.php | 5 +- 6 files changed, 219 insertions(+), 29 deletions(-) diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 87552aff6ddc..239cd52a9361 100755 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -1,5 +1,6 @@ keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement; } + //-------------------------------------------------------------------- + + /** + * Insert batch statement + * + * Generates a platform-specific insert string from the supplied data. + * + * @param string $table Table name + * @param array $keys INSERT keys + * @param array $values INSERT values + * + * @return string + */ + protected function _insertBatch(string $table, array $keys, array $values): string + { + return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $this->getFullName($table) . ' (' . implode(', ', $keys) . ') VALUES ' . implode(', ', $values); + } + + //-------------------------------------------------------------------- + /** * Update statement * @@ -218,12 +238,58 @@ protected function _update(string $table, array $values): string $fullTableName = $this->getFullName($table); - $statement = 'UPDATE ' . (empty($this->QBLimit) ? '' : 'TOP(' . $this->QBLimit . ') ') . $fullTableName . ' SET ' - . implode(', ', $valstr) . $this->compileWhereHaving('QBWhere') . $this->compileOrderBy(); + $statement = sprintf('UPDATE %s%s SET ', empty($this->QBLimit) ? '' : 'TOP(' . $this->QBLimit . ') ', $fullTableName); + + $statement .= implode(', ', $valstr) + . $this->compileWhereHaving('QBWhere') + . $this->compileOrderBy(); return $this->keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement; } + //-------------------------------------------------------------------- + + /** + * Update_Batch statement + * + * Generates a platform-specific batch update string from the supplied data + * + * @param string $table Table name + * @param array $values Update data + * @param string $index WHERE key + * + * @return string + */ + protected function _updateBatch(string $table, array $values, string $index): string + { + $ids = []; + $final = []; + + foreach ($values as $val) { + $ids[] = $val[$index]; + + foreach (array_keys($val) as $field) { + if ($field !== $index) { + $final[$field][] = 'WHEN ' . $index . ' = ' . $val[$index] . ' THEN ' . $val[$field]; + } + } + } + + $cases = ''; + + foreach ($final as $k => $v) { + $cases .= $k . " = CASE \n" + . implode("\n", $v) . "\n" + . 'ELSE ' . $k . ' END, '; + } + + $this->where($index . ' IN(' . implode(',', $ids) . ')', null, false); + + return 'UPDATE ' . $this->compileIgnore('update') . ' ' . $this->getFullName($table) . ' SET ' . substr($cases, 0, -2) . $this->compileWhereHaving('QBWhere'); + } + + //-------------------------------------------------------------------- + /** * Increments a numeric column by the specified value. * @@ -363,9 +429,10 @@ public function replace(array $set = null) return $sql; } - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($table) . ' ON'); + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' ON'); + $result = $this->db->query($sql, $this->binds, false); - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($table) . ' OFF'); + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' OFF'); return $result; } @@ -482,6 +549,44 @@ protected function maxMinAvgSum(string $select = '', string $alias = '', string return $this; } + //-------------------------------------------------------------------- + + /** + * "Count All" query + * + * Generates a platform-specific query string that counts all records in + * the particular table + * + * @param bool $reset Are we want to clear query builder values? + * + * @return int|string when $test = true + */ + public function countAll(bool $reset = true) + { + $table = $this->QBFrom[0]; + + $sql = $this->countString . $this->db->escapeIdentifiers('numrows') . ' FROM ' . $this->getFullName($table); + + if ($this->testMode) { + return $sql; + } + + $query = $this->db->query($sql, null, false); + if (empty($query->getResult())) { + return 0; + } + + $query = $query->getRow(); + + if ($reset === true) { + $this->resetSelect(); + } + + return (int) $query->numrows; + } + + //-------------------------------------------------------------------- + /** * Delete statement * diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 56b431d022e6..6a093be7b166 100755 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -1,5 +1,9 @@ escape($table); + $sql = 'EXEC sp_helpindex ' . $this->escape($this->schema . '.' . $table); if (($query = $this->query($sql)) === false) { throw new DatabaseException(lang('Database.failGetIndexData')); diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php index 6b5d13d4fa24..185bb1663c63 100755 --- a/system/Database/SQLSRV/Forge.php +++ b/system/Database/SQLSRV/Forge.php @@ -11,8 +11,10 @@ namespace CodeIgniter\Database\SQLSRV; +use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Forge as BaseForge; + /** * Forge for SQLSRV */ @@ -23,7 +25,7 @@ class Forge extends BaseForge * * @var string */ - protected $dropConstraintStr = 'ALTER TABLE %s DROP CONSTRAINT %s'; + protected $dropConstraintStr; /** * CREATE DATABASE IF statement @@ -61,7 +63,7 @@ class Forge extends BaseForge * * @var string */ - protected $renameTableStr = 'EXEC sp_rename %s , %s ;'; + protected $renameTableStr; /** * UNSIGNED support @@ -80,24 +82,36 @@ class Forge extends BaseForge * * @var string */ - protected $createTableIfStr = "IF NOT EXISTS (SELECT * FROM sysobjects WHERE ID = object_id(N'%s') AND OBJECTPROPERTY(id, N'IsUserTable') = 1)\nCREATE TABLE"; + protected $createTableIfStr; /** * CREATE TABLE statement * * @var string */ - protected $createTableStr = "%s %s (%s\n) "; - - /** - * DROP TABLE IF statement - * - * @var string - */ - protected $_drop_table_if = "IF EXISTS (SELECT * FROM sysobjects WHERE ID = object_id(N'%s') AND OBJECTPROPERTY(id, N'IsUserTable') = 1)\nDROP TABLE"; + protected $createTableStr; //-------------------------------------------------------------------- + public function __construct(BaseConnection $db) + { + parent::__construct($db); + + $this->createTableIfStr = "IF NOT EXISTS" + . "(SELECT t.name, s.name as schema_name, t.type_desc " + . "FROM sys.tables t " + . "INNER JOIN sys.schemas s on s.schema_id = t.schema_id " + . "WHERE s.name=N'" . $this->db->schema . "' " + . "AND t.name=REPLACE(N'%s', '\"', '') " + . "AND t.type_desc='USER_TABLE')\nCREATE TABLE "; + + $this->createTableStr = "%s " . $this->db->escapeIdentifiers($this->db->schema) . ".%s (%s\n) "; + + $this->renameTableStr = "EXEC sp_rename [" . $this->db->escapeIdentifiers($this->db->schema) . ".%s] , %s ;"; + + $this->dropConstraintStr = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.%s DROP CONSTRAINT %s'; + } + /** * CREATE TABLE attributes * @@ -121,9 +135,7 @@ protected function _createTableAttributes(array $attributes): string */ protected function _alterTable(string $alterType, string $table, $field) { - if ($alterType === 'ADD') { - return parent::_alterTable($alterType, $table, $field); - } + // Handle DROP here if ($alterType === 'DROP') { @@ -143,7 +155,7 @@ protected function _alterTable(string $alterType, string $table, $field) } } - $sql = 'ALTER TABLE [' . $table . '] DROP '; + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) . ' DROP '; $fields = array_map(static function ($item) { return 'COLUMN [' . trim($item) . ']'; @@ -152,10 +164,20 @@ protected function _alterTable(string $alterType, string $table, $field) return $sql . implode(',', $fields); } - $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table); + $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table); + + $sql .= ($alterType === 'ADD') ? 'ADD ' : ' '; $sqls = []; + if ($alterType === 'ADD') { + foreach ($field as $data) { + $sqls[] = $sql . ($data['_literal'] !== false ? $data['_literal'] : $this->_processColumn($data)); + } + + return $sqls; + } + foreach ($field as $data) { if ($data['_literal'] !== false) { return false; @@ -185,7 +207,6 @@ protected function _alterTable(string $alterType, string $table, $field) } if (! empty($data['new_name'])) { - // EXEC sp_rename '[dbo].[db_misc].[value]', 'valueasdasd', 'COLUMN'; $sqls[] = "EXEC sp_rename '[" . $this->db->schema . '].[' . $table . '].[' . $data['name'] . "]' , '" . $data['new_name'] . "', 'COLUMN';"; } } @@ -214,6 +235,51 @@ protected function _dropIndex(string $table, object $indexData) return $this->db->simpleQuery($sql); } + //-------------------------------------------------------------------- + + /** + * Process indexes + * + * @param string $table + * + * @return array|string + */ + protected function _processIndexes(string $table) + { + $sqls = []; + + for ($i = 0, $c = count($this->keys); $i < $c; $i++) { + $this->keys[$i] = (array) $this->keys[$i]; + + for ($i2 = 0, $c2 = count($this->keys[$i]); $i2 < $c2; $i2++) { + if (! isset($this->fields[$this->keys[$i][$i2]])) { + unset($this->keys[$i][$i2]); + } + } + if (count($this->keys[$i]) <= 0) { + continue; + } + + if (in_array($i, $this->uniqueKeys, true)) { + $sqls[] = 'ALTER TABLE ' + . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) + . ' ADD CONSTRAINT ' . $this->db->escapeIdentifiers($table . '_' . implode('_', $this->keys[$i])) + . ' UNIQUE (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i])) . ');'; + + continue; + } + + $sqls[] = 'CREATE INDEX ' + . $this->db->escapeIdentifiers($table . '_' . implode('_', $this->keys[$i])) + . ' ON ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table) + . ' (' . implode(', ', $this->db->escapeIdentifiers($this->keys[$i])) . ');'; + } + + return $sqls; + } + + //-------------------------------------------------------------------- + /** * Process column * @@ -257,8 +323,8 @@ protected function _processForeignKeys(string $table): string $nameIndex = $table . '_' . $field . '_foreign'; $sql .= ",\n\t CONSTRAINT " . $this->db->escapeIdentifiers($nameIndex) - . ' FOREIGN KEY (' . $this->db->escapeIdentifiers($field) . ') ' - . ' REFERENCES ' . $this->db->escapeIdentifiers($this->db->getPrefix() . $fkey['table']) . ' (' . $this->db->escapeIdentifiers($fkey['field']) . ')'; + . ' FOREIGN KEY (' . $this->db->escapeIdentifiers($field) . ') ' + . ' REFERENCES ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($this->db->getPrefix() . $fkey['table']) . ' (' . $this->db->escapeIdentifiers($fkey['field']) . ')'; if ($fkey['onDelete'] !== false && in_array($fkey['onDelete'], $allowActions, true)) { $sql .= ' ON DELETE ' . $fkey['onDelete']; diff --git a/system/Database/SQLSRV/Utils.php b/system/Database/SQLSRV/Utils.php index 0007faafbb37..d44158f93e32 100755 --- a/system/Database/SQLSRV/Utils.php +++ b/system/Database/SQLSRV/Utils.php @@ -1,5 +1,6 @@ optimizeTable = 'ALTER INDEX all ON ' . $this->db->schema . '.%s REORGANIZE'; + } + //-------------------------------------------------------------------- /** diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index 56f8a591ef66..48508c5a089b 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -41,7 +41,7 @@ public function testPrepareReturnsPreparedQuery() if ($this->db->DBDriver === 'SQLSRV') { $database = $this->db->getDatabase(); - $expected = "INSERT INTO {$ec}{$database}{$ec}.{$ec}dbo{$ec}.{$ec}{$pre}user{$ec} ({$ec}name{$ec},{$ec}email{$ec}) VALUES ({$placeholders})"; + $expected = "INSERT INTO {$ec}{$database}{$ec}.{$ec}{$this->db->schema}{$ec}.{$ec}{$pre}user{$ec} ({$ec}name{$ec},{$ec}email{$ec}) VALUES ({$placeholders})"; } else { $expected = "INSERT INTO {$ec}{$pre}user{$ec} ({$ec}name{$ec}, {$ec}email{$ec}) VALUES ({$placeholders})"; } @@ -100,6 +100,10 @@ public function testExecuteRunsQueryAndReturnsManualResultObject() $query = $this->db->prepare(static function ($db) { $sql = "INSERT INTO {$db->DBPrefix}user (name, email, country) VALUES (?, ?, ?)"; + if ($db->DBDriver === 'SQLSRV') { + $sql = "INSERT INTO {$db->schema}.{$db->DBPrefix}user (name, email, country) VALUES (?, ?, ?)"; + } + return (new Query($db))->setQuery($sql); }); diff --git a/tests/system/Database/Migrations/MigrationRunnerTest.php b/tests/system/Database/Migrations/MigrationRunnerTest.php index 688f9b8d8cb0..9adb744a7617 100644 --- a/tests/system/Database/Migrations/MigrationRunnerTest.php +++ b/tests/system/Database/Migrations/MigrationRunnerTest.php @@ -84,7 +84,7 @@ public function testGetHistory() ]; if ($this->db->DBDriver === 'SQLSRV') { - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->prefixTable('migrations') . ' ON'); + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->prefixTable('migrations') . ' ON'); } $this->hasInDatabase('migrations', $history); @@ -92,8 +92,7 @@ public function testGetHistory() $this->assertEquals($history, (array) $runner->getHistory()[0]); if ($this->db->DBDriver === 'SQLSRV') { - $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->prefixTable('migrations') . ' OFF'); - + $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->prefixTable('migrations') . ' OFF'); $db = $this->getPrivateProperty($runner, 'db'); $db->table('migrations')->delete(['id' => 4]); } From 32a3aa1bbe3761c35d50c7e26a40622d687732b0 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sat, 3 Jul 2021 23:35:28 +0000 Subject: [PATCH 42/65] Implement Deptrac --- .github/workflows/test-deptrac.yml | 82 ++++++++++ depfile.yaml | 232 +++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 .github/workflows/test-deptrac.yml create mode 100644 depfile.yaml diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml new file mode 100644 index 000000000000..9a9179b501d8 --- /dev/null +++ b/.github/workflows/test-deptrac.yml @@ -0,0 +1,82 @@ +# When a PR is opened or a push is made, perform an +# architectural inspection on the code using Deptrac. +name: Deptrac + +on: + pull_request: + branches: + - 'develop' + - '4.*' + paths: + - 'app/**' + - 'system/**' + - 'composer.json' + - 'depfile.yaml' + - '.github/workflows/test-deptrac.yml' + push: + branches: + - 'develop' + - '4.*' + paths: + - 'app/**' + - 'system/**' + - 'composer.json' + - 'depfile.yaml' + - '.github/workflows/test-deptrac.yml' + +jobs: + build: + name: PHP ${{ matrix.php-versions }} Architectural Inspection + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + php-versions: ['7.4', '8.0'] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer, pecl, phive, phpunit + extensions: intl, json, mbstring, gd, mysqlnd, xdebug, xml, sqlite3 + + - name: Use latest Composer + run: composer self-update + + - name: Validate composer.json + run: composer validate --strict + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Create composer cache directory + run: mkdir -p ${{ steps.composer-cache.outputs.dir }} + + - name: Cache composer dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Create Deptrac cache directory + run: mkdir -p build/ + + - name: Cache Deptrac results + uses: actions/cache@v2 + with: + path: build + key: ${{ runner.os }}-deptrac-${{ github.sha }} + restore-keys: ${{ runner.os }}-deptrac- + + - name: Install dependencies + run: composer update --ansi --no-interaction + + - name: Run architectural inspection + run: | + sudo phive --no-progress install --global qossmic/deptrac --trust-gpg-keys B8F640134AB1782E + deptrac analyze --cache-file=build/deptrac.cache diff --git a/depfile.yaml b/depfile.yaml new file mode 100644 index 000000000000..147099379891 --- /dev/null +++ b/depfile.yaml @@ -0,0 +1,232 @@ +# Defines the layers for each framework +# component and their allowed interactions. +# The following components are exempt +# due to their global nature: +# - CLI & Commands +# - Config +# - Debug +# - Exception +# - Service +# - Validation\FormatRules +paths: + - ./app + - ./system +exclude_files: + - '#.*test.*#i' +layers: + - name: API + collectors: + - type: className + regex: ^Codeigniter\\API\\.* + - name: Cache + collectors: + - type: className + regex: ^Codeigniter\\Cache\\.* + - name: Controller + collectors: + - type: className + regex: ^CodeIgniter\\Controller$ + - name: Cookie + collectors: + - type: className + regex: ^Codeigniter\\Cookie\\.* + - name: Database + collectors: + - type: className + regex: ^Codeigniter\\Database\\.* + - name: Email + collectors: + - type: className + regex: ^Codeigniter\\Email\\.* + - name: Encryption + collectors: + - type: className + regex: ^Codeigniter\\Encryption\\.* + - name: Entity + collectors: + - type: className + regex: ^Codeigniter\\Entity\\.* + - name: Events + collectors: + - type: className + regex: ^Codeigniter\\Events\\.* + - name: Files + collectors: + - type: className + regex: ^Codeigniter\\Files\\.* + - name: Filters + collectors: + - type: bool + must: + - type: className + regex: ^Codeigniter\\Filters\\Filter.* + - name: Format + collectors: + - type: className + regex: ^Codeigniter\\Format\\.* + - name: Honeypot + collectors: + - type: className + regex: ^Codeigniter\\.*Honeypot.* # includes the Filter + - name: HTTP + collectors: + - type: bool + must: + - type: className + regex: ^Codeigniter\\HTTP\\.* + must_not: + - type: className + regex: (Exception|URI) + - name: I18n + collectors: + - type: className + regex: ^Codeigniter\\I18n\\.* + - name: Images + collectors: + - type: className + regex: ^Codeigniter\\Images\\.* + - name: Language + collectors: + - type: className + regex: ^Codeigniter\\Language\\.* + - name: Log + collectors: + - type: className + regex: ^Codeigniter\\Log\\.* + - name: Model + collectors: + - type: className + regex: ^Codeigniter\\.*Model$ + - name: Modules + collectors: + - type: className + regex: ^Codeigniter\\Modules\\.* + - name: Pager + collectors: + - type: className + regex: ^Codeigniter\\Pager\\.* + - name: Publisher + collectors: + - type: className + regex: ^Codeigniter\\Publisher\\.* + - name: RESTful + collectors: + - type: className + regex: ^Codeigniter\\RESTful\\.* + - name: Router + collectors: + - type: className + regex: ^Codeigniter\\Router\\.* + - name: Security + collectors: + - type: className + regex: ^Codeigniter\\Security\\.* + - name: Session + collectors: + - type: className + regex: ^Codeigniter\\Session\\.* + - name: Throttle + collectors: + - type: className + regex: ^Codeigniter\\Throttle\\.* + - name: Typography + collectors: + - type: className + regex: ^Codeigniter\\Typography\\.* + - name: URI + collectors: + - type: className + regex: ^CodeIgniter\\HTTP\\URI$ + - name: Validation + collectors: + - type: bool + must: + - type: className + regex: ^Codeigniter\\Validation\\.* + must_not: + - type: className + regex: ^Codeigniter\\Validation\\FormatRules$ + - name: View + collectors: + - type: className + regex: ^Codeigniter\\View\\.* +ruleset: + API: + - Format + - HTTP + Controller: + - HTTP + - Validation + Database: + - Entity + - Events + Email: + - Events + Entity: + - I18n + Filters: + - HTTP + Honeypot: + - Filters + - HTTP + HTTP: + - Cookie + - Files + - URI + Images: + - Files + Model: + - Database + - I18n + - Pager + - Validation + Pager: + - URI + - View + Publisher: + - Files + - URI + RESTful: # @todo Transitive Dependency + - API + - Controller + - Format + - HTTP + - Validation + Router: + - HTTP + Security: + - Cookie + - HTTP + Session: + - Cookie + - Database + Throttle: + - Cache + Validation: + - HTTP + View: + - Cache +skip_violations: + # Individual class exemptions + CodeIgniter\Entity\Cast\URICast: + - CodeIgniter\HTTP\URI + CodeIgniter\Log\Handlers\ChromeLoggerHandler: + - CodeIgniter\HTTP\ResponseInterface + CodeIgniter\View\Table: + - CodeIgniter\Database\BaseResult + CodeIgniter\View\Plugins: + - CodeIgniter\HTTP\URI + + # BC changes that should be fixed + CodeIgniter\HTTP\ResponseTrait: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\ResponseInterface: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\Response: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\RedirectResponse: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\HTTP\DownloadResponse: + - CodeIgniter\Pager\PagerInterface + CodeIgniter\Validation\Validation: + - CodeIgniter\View\RendererInterface From b3e4c315b8030c5f8189ccd640d100254ee68132 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 4 Jul 2021 10:38:37 +0200 Subject: [PATCH 43/65] Update Throttler docs [ci skip] --- user_guide_src/source/libraries/throttler.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_guide_src/source/libraries/throttler.rst b/user_guide_src/source/libraries/throttler.rst index 9513d266ea16..1181c49affe4 100644 --- a/user_guide_src/source/libraries/throttler.rst +++ b/user_guide_src/source/libraries/throttler.rst @@ -79,7 +79,7 @@ along the lines of:: // Restrict an IP address to no more // than 1 request per second across the // entire site. - if ($throttler->check($request->getIPAddress(), 60, MINUTE) === false) + if ($throttler->check(md5($request->getIPAddress()), 60, MINUTE) === false) { return Services::response()->setStatusCode(429); } From 016380add6a7f3be7c451c0ffbca1b7567e46133 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 4 Jul 2021 11:34:23 +0200 Subject: [PATCH 44/65] Fix getVar method when trying to get variable that does not exists --- system/HTTP/IncomingRequest.php | 4 ++++ tests/system/HTTP/IncomingRequestTest.php | 2 ++ 2 files changed, 6 insertions(+) diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 2a8da2f49129..15e78ff4a94f 100755 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -583,6 +583,10 @@ public function getJsonVar(string $index, bool $assoc = false, ?int $filter = nu $data = dot_array_search($index, $this->getJSON(true)); + if ($data === null) { + return null; + } + if (! is_array($data)) { $filter = $filter ?? FILTER_DEFAULT; $flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0); diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 4a2b6c086bcf..2a2b9f75e3ca 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -318,6 +318,7 @@ public function testCanGetAVariableFromJson() $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); $this->assertEquals('bar', $request->getJsonVar('foo')); + $this->assertSame(null, $request->getJsonVar('notExists')); $jsonVar = $request->getJsonVar('baz'); $this->assertIsObject($jsonVar); $this->assertEquals('buzz', $jsonVar->fizz); @@ -373,6 +374,7 @@ public function testGetVarWorksWithJson() $this->assertEquals('bar', $request->getVar('foo')); $this->assertEquals('buzz', $request->getVar('fizz')); + $this->assertSame(null, $request->getVar('notExists')); $multiple = $request->getVar(['foo', 'fizz']); $this->assertIsArray($multiple); From 43db35c97c2c513668448a177200e25e3dff7773 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 4 Jul 2021 21:20:17 +0200 Subject: [PATCH 45/65] Update tests/system/HTTP/IncomingRequestTest.php Co-authored-by: MGatner --- tests/system/HTTP/IncomingRequestTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 2a2b9f75e3ca..d3f45159874a 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -318,7 +318,7 @@ public function testCanGetAVariableFromJson() $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); $this->assertEquals('bar', $request->getJsonVar('foo')); - $this->assertSame(null, $request->getJsonVar('notExists')); + $this->assertNull($request->getJsonVar('notExists')); $jsonVar = $request->getJsonVar('baz'); $this->assertIsObject($jsonVar); $this->assertEquals('buzz', $jsonVar->fizz); From f3952ebd78726adb8a3be97739369d86fc34ee9e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 4 Jul 2021 21:20:23 +0200 Subject: [PATCH 46/65] Update tests/system/HTTP/IncomingRequestTest.php Co-authored-by: MGatner --- tests/system/HTTP/IncomingRequestTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index d3f45159874a..95305ddf0732 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -374,7 +374,7 @@ public function testGetVarWorksWithJson() $this->assertEquals('bar', $request->getVar('foo')); $this->assertEquals('buzz', $request->getVar('fizz')); - $this->assertSame(null, $request->getVar('notExists')); + $this->assertNull($request->getVar('notExists')); $multiple = $request->getVar(['foo', 'fizz']); $this->assertIsArray($multiple); From bb63702f69e493e3501e6c393ec526e2ea632f8b Mon Sep 17 00:00:00 2001 From: Alex Schmitz Date: Mon, 5 Jul 2021 09:24:42 +0200 Subject: [PATCH 47/65] Add toolbar timer styles to sass files. --- admin/css/debug-toolbar/toolbar.scss | 53 ++++++++++++++++++++++++++ system/Debug/Toolbar/Views/toolbar.css | 36 ++++++++--------- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/admin/css/debug-toolbar/toolbar.scss b/admin/css/debug-toolbar/toolbar.scss index 8669d0ac12ff..d06731db6ffe 100644 --- a/admin/css/debug-toolbar/toolbar.scss +++ b/admin/css/debug-toolbar/toolbar.scss @@ -326,6 +326,23 @@ &:first-child { border-left: 0; + max-width: none; + } + + &.child-container { + padding: 0px; + + .timeline { + margin: 0px; + + td { + &:first-child { + &:not(.child-container) { + padding-left: calc(5px + 10px * var(--level)); + } + } + } + } } } @@ -336,6 +353,42 @@ position: absolute; top: 30%; } + + .timeline-parent{ + cursor: pointer; + + td { + &:first-child { + nav { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; + background-position: 0 25%; + display: inline-block; + height: 15px; + width: 15px; + margin-right: 3px; + vertical-align: middle; + } + } + } + } + + .timeline-parent-open { + background-color: #DFDFDF; + + td { + &:first-child { + nav { + background-position: 0 75%; + } + } + } + } + + .child-row { + &:hover { + background: transparent; + } + } } // The "Routes" tab diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index bc8b4ac44ef7..bd080de2b1c8 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -186,22 +186,6 @@ #debug-bar .timeline { margin-left: 0; width: 100%; } - #debug-bar .timeline tr.timeline-parent { - cursor: pointer; } - #debug-bar .timeline tr.timeline-parent td:first-child nav { - background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; - background-position: 0 25%; - display: inline-block; - height: 15px; - width: 15px; - margin-right: 3px; - vertical-align: middle; } - #debug-bar .timeline tr.timeline-parent.timeline-parent-open td:first-child nav { - background-position: 0 75%; } - #debug-bar .timeline tr.timeline-parent.timeline-parent-open { - background-color: #DFDFDF; } - #debug-bar .timeline tr.child-row:hover { - background: transparent; } #debug-bar .timeline th { border-left: 1px solid; font-size: 12px; @@ -220,9 +204,9 @@ max-width: none; } #debug-bar .timeline td.child-container { padding: 0px; } - #debug-bar .timeline td.child-container .timeline{ + #debug-bar .timeline td.child-container .timeline { margin: 0px; } - #debug-bar .timeline td.child-container td:first-child:not(.child-container){ + #debug-bar .timeline td.child-container .timeline td:first-child:not(.child-container) { padding-left: calc(5px + 10px * var(--level)); } #debug-bar .timeline .timer { border-radius: 4px; @@ -232,6 +216,22 @@ padding: 5px; position: absolute; top: 30%; } + #debug-bar .timeline .timeline-parent { + cursor: pointer; } + #debug-bar .timeline .timeline-parent td:first-child nav { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; + background-position: 0 25%; + display: inline-block; + height: 15px; + width: 15px; + margin-right: 3px; + vertical-align: middle; } + #debug-bar .timeline .timeline-parent-open { + background-color: #DFDFDF; } + #debug-bar .timeline .timeline-parent-open td:first-child nav { + background-position: 0 75%; } + #debug-bar .timeline .child-row:hover { + background: transparent; } #debug-bar .route-params, #debug-bar .route-params-item { vertical-align: top; } From e3857cd88c237211495cf9a1e52760f8a038e4a4 Mon Sep 17 00:00:00 2001 From: Alex Schmitz Date: Tue, 6 Jul 2021 12:34:23 +0200 Subject: [PATCH 48/65] Do not overwrite startTime. --- system/CodeIgniter.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 6af18d184606..c1508b9c9b9e 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -511,7 +511,9 @@ protected function bootstrapEnvironment() */ protected function startBenchmark() { - $this->startTime = microtime(true); + if($this->startTime === null) { + $this->startTime = microtime(true); + } $this->benchmark = Services::timer(); $this->benchmark->start('total_execution', $this->startTime); From 9d8ea6fbd5f53323ca3d92e496f39ba9c9c1167e Mon Sep 17 00:00:00 2001 From: Alex Schmitz Date: Wed, 7 Jul 2021 13:21:55 +0200 Subject: [PATCH 49/65] Unify scss styling and switch to spaces. --- admin/css/debug-toolbar/_graphic-charter.scss | 18 +- admin/css/debug-toolbar/_mixins.scss | 13 +- admin/css/debug-toolbar/_theme-dark.scss | 257 +++--- admin/css/debug-toolbar/_theme-light.scss | 250 +++--- admin/css/debug-toolbar/toolbar.scss | 838 +++++++++--------- system/Debug/Toolbar/Views/toolbar.css | 23 +- 6 files changed, 718 insertions(+), 681 deletions(-) diff --git a/admin/css/debug-toolbar/_graphic-charter.scss b/admin/css/debug-toolbar/_graphic-charter.scss index 9e88e7177551..522f07f6e255 100644 --- a/admin/css/debug-toolbar/_graphic-charter.scss +++ b/admin/css/debug-toolbar/_graphic-charter.scss @@ -2,19 +2,19 @@ // ========================================================================== */ // Themes -$t-dark: #252525; +$t-dark: #252525; $t-light: #FFFFFF; // Glossy colors -$g-blue: #5BC0DE; -$g-gray: #434343; -$g-green: #9ACE25; +$g-blue: #5BC0DE; +$g-gray: #434343; +$g-green: #9ACE25; $g-orange: #DD8615; -$g-red: #DD4814; +$g-red: #DD4814; // Matt colors -$m-blue: #D8EAF0; -$m-gray: #DFDFDF; -$m-green: #DFF0D8; +$m-blue: #D8EAF0; +$m-gray: #DFDFDF; +$m-green: #DFF0D8; $m-orange: #FDC894; -$m-red: #EF9090; +$m-red: #EF9090; diff --git a/admin/css/debug-toolbar/_mixins.scss b/admin/css/debug-toolbar/_mixins.scss index c5bde5f5f6da..69af2b67c475 100644 --- a/admin/css/debug-toolbar/_mixins.scss +++ b/admin/css/debug-toolbar/_mixins.scss @@ -2,12 +2,13 @@ // ========================================================================== */ @mixin border-radius($radius) { - border-radius: $radius; - -moz-border-radius: $radius; - -webkit-border-radius: $radius; + border-radius: $radius; + -moz-border-radius: $radius; + -webkit-border-radius: $radius; } + @mixin box-shadow($left, $top, $radius, $color) { - box-shadow: $left $top $radius $color; - -moz-box-shadow: $left $top $radius $color; - -webkit-box-shadow: $left $top $radius $color; + box-shadow: $left $top $radius $color; + -moz-box-shadow: $left $top $radius $color; + -webkit-box-shadow: $left $top $radius $color; } diff --git a/admin/css/debug-toolbar/_theme-dark.scss b/admin/css/debug-toolbar/_theme-dark.scss index 56d4a4bbbac3..b41e8fb086af 100644 --- a/admin/css/debug-toolbar/_theme-dark.scss +++ b/admin/css/debug-toolbar/_theme-dark.scss @@ -12,11 +12,14 @@ // ========================================================================== */ #debug-icon { - background-color: $t-dark; - @include box-shadow(0, 0, 4px, $m-gray); - a:active, a:link, a:visited { - color: $g-orange; - } + background-color: $t-dark; + @include box-shadow(0, 0, 4px, $m-gray); + + a:active, + a:link, + a:visited { + color: $g-orange; + } } @@ -24,119 +27,130 @@ // ========================================================================== */ #debug-bar { - background-color: $t-dark; - color: $m-gray; - - // Reset to prevent conflict with other CSS files - h1, - h2, - h3, - p, - a, - button, - table, - thead, - tr, - td, - button, - .toolbar { - background-color: transparent; - color: $m-gray; - } - - // Buttons - button { - background-color: $t-dark; - } - - // Tables - table { - strong { - color: $m-orange; - } - tbody tr { - &:hover { - background-color: $g-gray; - } - &.current { - background-color: $m-orange; - td { - color: $t-dark; - } - &:hover td { - background-color: $g-red; - color: $t-light; - } - } - } - } - - // The toolbar - .toolbar { - background-color: $g-gray; - @include box-shadow(0, 0, 4px, $g-gray); - img { - filter: brightness(0) invert(1); - } - } - - // Fixed top - &.fixed-top { - & .toolbar { - @include box-shadow(0, 0, 4px, $g-gray); - } - .tab { - @include box-shadow(0, 1px, 4px, $g-gray); - } - } - - // "Muted" elements - .muted { - color: $m-gray; - td { - color: $g-gray; - } - &:hover td { - color: $m-gray; - } - } - - // The toolbar preferences - #toolbar-position, - #toolbar-theme, { - filter: brightness(0) invert(0.6); - } - - // The toolbar menus - .ci-label { - &.active { - background-color: $t-dark; - } - &:hover { - background-color: $t-dark; - } - .badge { - background-color: $g-blue; - color: $m-gray; - } - } - - // The tabs container - .tab { - background-color: $t-dark; - @include box-shadow(0, -1px, 4px, $g-gray); - } - - // The "Timeline" tab - .timeline { - th, - td { - border-color: $g-gray; - } - .timer { - background-color: $g-orange; - } - } + background-color: $t-dark; + color: $m-gray; + + // Reset to prevent conflict with other CSS files + h1, + h2, + h3, + p, + a, + button, + table, + thead, + tr, + td, + button, + .toolbar { + background-color: transparent; + color: $m-gray; + } + + // Buttons + button { + background-color: $t-dark; + } + + // Tables + table { + strong { + color: $m-orange; + } + + tbody tr { + &:hover { + background-color: $g-gray; + } + + &.current { + background-color: $m-orange; + + td { + color: $t-dark; + } + + &:hover td { + background-color: $g-red; + color: $t-light; + } + } + } + } + + // The toolbar + .toolbar { + background-color: $g-gray; + @include box-shadow(0, 0, 4px, $g-gray); + + img { + filter: brightness(0) invert(1); + } + } + + // Fixed top + &.fixed-top { + .toolbar { + @include box-shadow(0, 0, 4px, $g-gray); + } + + .tab { + @include box-shadow(0, 1px, 4px, $g-gray); + } + } + + // "Muted" elements + .muted { + color: $m-gray; + + td { + color: $g-gray; + } + + &:hover td { + color: $m-gray; + } + } + + // The toolbar preferences + #toolbar-position, + #toolbar-theme { + filter: brightness(0) invert(0.6); + } + + // The toolbar menus + .ci-label { + &.active { + background-color: $t-dark; + } + + &:hover { + background-color: $t-dark; + } + + .badge { + background-color: $g-blue; + color: $m-gray; + } + } + + // The tabs container + .tab { + background-color: $t-dark; + @include box-shadow(0, -1px, 4px, $g-gray); + } + + // The "Timeline" tab + .timeline { + th, + td { + border-color: $g-gray; + } + + .timer { + background-color: $g-orange; + } + } } @@ -144,9 +158,10 @@ // ========================================================================== */ .debug-view.show-view { - border-color: $g-orange; + border-color: $g-orange; } + .debug-view-path { - background-color: $m-orange; - color: $g-gray; + background-color: $m-orange; + color: $g-gray; } diff --git a/admin/css/debug-toolbar/_theme-light.scss b/admin/css/debug-toolbar/_theme-light.scss index 744997a4080d..7148cd4c4f6f 100644 --- a/admin/css/debug-toolbar/_theme-light.scss +++ b/admin/css/debug-toolbar/_theme-light.scss @@ -12,11 +12,14 @@ // ========================================================================== */ #debug-icon { - background-color: $t-light; - @include box-shadow(0, 0, 4px, $m-gray); - a:active, a:link, a:visited { - color: $g-orange; - } + background-color: $t-light; + @include box-shadow(0, 0, 4px, $m-gray); + + a:active, + a:link, + a:visited { + color: $g-orange; + } } @@ -24,116 +27,126 @@ // ========================================================================== */ #debug-bar { - background-color: $t-light; - color: $g-gray; - - // Reset to prevent conflict with other CSS files */ - h1, - h2, - h3, - p, - a, - button, - table, - thead, - tr, - td, - button, - .toolbar { - background-color: transparent; - color: $g-gray; - } - - // Buttons - button { - background-color: $t-light; - } - - // Tables - table { - strong { - color: $m-orange; - } - tbody tr { - &:hover { - background-color: $m-gray; - } - &.current { - background-color: $m-orange; - &:hover td { - background-color: $g-red; - color: $t-light; - } - } - } - } - - // The toolbar - .toolbar { - background-color: $t-light; - @include box-shadow(0, 0, 4px, $m-gray); - img { - filter: brightness(0) invert(0.4); - } - } - - // Fixed top - &.fixed-top { - & .toolbar { - @include box-shadow(0, 0, 4px, $m-gray); - } - .tab { - @include box-shadow(0, 1px, 4px, $m-gray); - } - } - - // "Muted" elements - .muted { - color: $g-gray; - td { - color: $m-gray; - } - &:hover td { - color: $g-gray; - } - } - - // The toolbar preferences - #toolbar-position, - #toolbar-theme, { - filter: brightness(0) invert(0.6); - } - - // The toolbar menus - .ci-label { - &.active { - background-color: $m-gray; - } - &:hover { - background-color: $m-gray; - } - .badge { - background-color: $g-blue; - color: $t-light; - } - } - - // The tabs container - .tab { - background-color: $t-light; - @include box-shadow(0, -1px, 4px, $m-gray); - } - - // The "Timeline" tab - .timeline { - th, - td { - border-color: $m-gray; - } - .timer { - background-color: $g-orange; - } - } + background-color: $t-light; + color: $g-gray; + + // Reset to prevent conflict with other CSS files + h1, + h2, + h3, + p, + a, + button, + table, + thead, + tr, + td, + button, + .toolbar { + background-color: transparent; + color: $g-gray; + } + + // Buttons + button { + background-color: $t-light; + } + + // Tables + table { + strong { + color: $m-orange; + } + + tbody tr { + &:hover { + background-color: $m-gray; + } + + &.current { + background-color: $m-orange; + + &:hover td { + background-color: $g-red; + color: $t-light; + } + } + } + } + + // The toolbar + .toolbar { + background-color: $t-light; + @include box-shadow(0, 0, 4px, $m-gray); + + img { + filter: brightness(0) invert(0.4); + } + } + + // Fixed top + &.fixed-top { + .toolbar { + @include box-shadow(0, 0, 4px, $m-gray); + } + + .tab { + @include box-shadow(0, 1px, 4px, $m-gray); + } + } + + // "Muted" elements + .muted { + color: $g-gray; + + td { + color: $m-gray; + } + + &:hover td { + color: $g-gray; + } + } + + // The toolbar preferences + #toolbar-position, + #toolbar-theme { + filter: brightness(0) invert(0.6); + } + + // The toolbar menus + .ci-label { + &.active { + background-color: $m-gray; + } + + &:hover { + background-color: $m-gray; + } + + .badge { + background-color: $g-blue; + color: $t-light; + } + } + + // The tabs container + .tab { + background-color: $t-light; + @include box-shadow(0, -1px, 4px, $m-gray); + } + + // The "Timeline" tab + .timeline { + th, + td { + border-color: $m-gray; + } + + .timer { + background-color: $g-orange; + } + } } @@ -141,9 +154,10 @@ // ========================================================================== */ .debug-view.show-view { - border-color: $g-orange; + border-color: $g-orange; } + .debug-view-path { - background-color: $m-orange; - color: $g-gray; + background-color: $m-orange; + color: $g-gray; } diff --git a/admin/css/debug-toolbar/toolbar.scss b/admin/css/debug-toolbar/toolbar.scss index d06731db6ffe..0f07005ad9ab 100644 --- a/admin/css/debug-toolbar/toolbar.scss +++ b/admin/css/debug-toolbar/toolbar.scss @@ -1,8 +1,8 @@ /*! CodeIgniter 4 - Debug bar * ============================================================================ - * Forum: https://forum.codeigniter.com - * Github: https://github.com/codeigniter4/codeigniter4 - * Slack: https://codeigniterchat.slack.com + * Forum: https://forum.codeigniter.com + * Github: https://github.com/codeigniter4/codeigniter4 + * Slack: https://codeigniterchat.slack.com * Website: https://codeigniter.com */ @@ -17,38 +17,38 @@ // ========================================================================== */ #debug-icon { - // Position - bottom: 0; - position: fixed; - right: 0; - z-index: 10000; - - // Size - height: 36px; - width: 36px; - - // Spacing - margin: 0px; - padding: 0px; - - // Content - clear: both; - text-align: center; - - a svg { - margin: 8px; - max-width: 20px; - max-height: 20px; - } - - &.fixed-top { - bottom: auto; - top: 0; - } - - .debug-bar-ndisplay { - display: none; - } + // Position + bottom: 0; + position: fixed; + right: 0; + z-index: 10000; + + // Size + height: 36px; + width: 36px; + + // Spacing + margin: 0px; + padding: 0px; + + // Content + clear: both; + text-align: center; + + a svg { + margin: 8px; + max-width: 20px; + max-height: 20px; + } + + &.fixed-top { + bottom: auto; + top: 0; + } + + .debug-bar-ndisplay { + display: none; + } } @@ -56,352 +56,352 @@ // ========================================================================== */ #debug-bar { - // Position - bottom: 0; - left: 0; - position: fixed; - right: 0; - z-index: 10000; - - // Size - height: 36px; - - // Spacing - line-height: 36px; - - // Typography - font-family: $base-font; - font-size: $base-size; - font-weight: 400; - - // General elements - h1 { - bottom: 0; - display: inline-block; - font-size: $base-size - 2; - font-weight: normal; - margin: 0 16px 0 0; - padding: 0; - position: absolute; - right: 30px; - text-align: left; - top: 0; - - svg { - width: 16px; - margin-right: 5px; - } - } - - h2 { - font-size: $base-size; - margin: 0; - padding: 5px 0 10px 0; - - span { - font-size: 13px; - } - } - - h3 { - font-size: $base-size - 4; - font-weight: 200; - margin: 0 0 0 10px; - padding: 0; - text-transform: uppercase; - } - - p { - font-size: $base-size - 4; - margin: 0 0 0 15px; - padding: 0; - } - - a { - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - button { - border: 1px solid; - @include border-radius(4px); - cursor: pointer; - line-height: 15px; - - &:hover { - text-decoration: underline; - } - } - - table { - border-collapse: collapse; - font-size: $base-size - 2; - line-height: normal; - margin: 5px 10px 15px 10px; // Tables indentation - width: calc(100% - 10px); // Make sure it still fits the container, even with the margins - - strong { - font-weight: 500; - } - - th { - display: table-cell; - font-weight: 600; - padding-bottom: 0.7em; - text-align: left; - } - - tr { - border: none; - } - - td { - border: none; - display: table-cell; - margin: 0; - text-align: left; - - &:first-child { - max-width: 20%; - - &.narrow { - width: 7em; - } - } - } - } - - td[data-debugbar-route] { - form { - display: none; - } - - &:hover { - form { - display: block; - } - - &>div { - display: none; - } - } - - input[type=text] { - padding: 2px; - } - } - - // The toolbar - .toolbar { - display: flex; - overflow: hidden; - overflow-y: auto; - padding: 0 12px 0 12px; - /* give room for OS X scrollbar */ - white-space: nowrap; - z-index: 10000; - } - - // Fixed top - &.fixed-top { - bottom: auto; - top: 0; - - .tab { - bottom: auto; - top: 36px; - } - } - - // The toolbar preferences - #toolbar-position, - #toolbar-theme { - a { - // float: left; - padding: 0 6px; - display: inline-flex; - vertical-align: top; - - &:hover { - text-decoration: none; - } - } - } - - // The "Open/Close" toggle - #debug-bar-link { - bottom: 0; - display: inline-block; - font-size: $base-size; - line-height: 36px; - padding: 6px; - position: absolute; - right: 10px; - top: 0; - width: 24px; - } - - // The toolbar menus - .ci-label { - display: inline-flex; - font-size: $base-size - 2; - // vertical-align: baseline; - - &:hover { - cursor: pointer; - } - - a { - color: inherit; - display: flex; - letter-spacing: normal; - padding: 0 10px; - text-decoration: none; - align-items: center; - } - - // The toolbar icons - img { - // clear: left; - // display: inline-block; - // float: left; - margin: 6px 3px 6px 0; - width: 16px !important; - } - - // The toolbar notification badges - .badge { - @include border-radius(12px); - display: inline-block; - font-size: 75%; - font-weight: bold; - line-height: 12px; - margin-left: 5px; - padding: 2px 5px; - text-align: center; - vertical-align: baseline; - white-space: nowrap; - } - } - - // The tabs container - .tab { - bottom: 35px; - display: none; - left: 0; - max-height: 62%; - overflow: hidden; - overflow-y: auto; - padding: 1em 2em; - position: fixed; - right: 0; - z-index: 9999; - } - - // The "Timeline" tab - .timeline { - margin-left: 0; - width: 100%; - - th { - border-left: 1px solid; - font-size: $base-size - 4; - font-weight: 200; - padding: 5px 5px 10px 5px; - position: relative; - text-align: left; - - &:first-child { - border-left: 0; - } - } - - td { - border-left: 1px solid; - padding: 5px; - position: relative; - - &:first-child { - border-left: 0; - max-width: none; - } - - &.child-container { - padding: 0px; - - .timeline { - margin: 0px; - - td { - &:first-child { - &:not(.child-container) { - padding-left: calc(5px + 10px * var(--level)); - } - } - } - } - } - } - - .timer { - @include border-radius(4px); - display: inline-block; - padding: 5px; - position: absolute; - top: 30%; - } - - .timeline-parent{ - cursor: pointer; - - td { - &:first-child { - nav { - background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; - background-position: 0 25%; - display: inline-block; - height: 15px; - width: 15px; - margin-right: 3px; - vertical-align: middle; - } - } - } - } - - .timeline-parent-open { - background-color: #DFDFDF; - - td { - &:first-child { - nav { - background-position: 0 75%; - } - } - } - } - - .child-row { - &:hover { - background: transparent; - } - } - } - - // The "Routes" tab - .route-params, - .route-params-item { - vertical-align: top; - - td:first-child { - font-style: italic; - padding-left: 1em; - text-align: right; - } - } + // Position + bottom: 0; + left: 0; + position: fixed; + right: 0; + z-index: 10000; + + // Size + height: 36px; + + // Spacing + line-height: 36px; + + // Typography + font-family: $base-font; + font-size: $base-size; + font-weight: 400; + + // General elements + h1 { + bottom: 0; + display: inline-block; + font-size: $base-size - 2; + font-weight: normal; + margin: 0 16px 0 0; + padding: 0; + position: absolute; + right: 30px; + text-align: left; + top: 0; + + svg { + width: 16px; + margin-right: 5px; + } + } + + h2 { + font-size: $base-size; + margin: 0; + padding: 5px 0 10px 0; + + span { + font-size: 13px; + } + } + + h3 { + font-size: $base-size - 4; + font-weight: 200; + margin: 0 0 0 10px; + padding: 0; + text-transform: uppercase; + } + + p { + font-size: $base-size - 4; + margin: 0 0 0 15px; + padding: 0; + } + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + button { + border: 1px solid; + @include border-radius(4px); + cursor: pointer; + line-height: 15px; + + &:hover { + text-decoration: underline; + } + } + + table { + border-collapse: collapse; + font-size: $base-size - 2; + line-height: normal; + + // Tables indentation + margin: 5px 10px 15px 10px; + + // Make sure it still fits the container, even with the margins + width: calc(100% - 10px); + + strong { + font-weight: 500; + } + + th { + display: table-cell; + font-weight: 600; + padding-bottom: 0.7em; + text-align: left; + } + + tr { + border: none; + } + + td { + border: none; + display: table-cell; + margin: 0; + text-align: left; + + &:first-child { + max-width: 20%; + + &.narrow { + width: 7em; + } + } + } + } + + td[data-debugbar-route] { + form { + display: none; + } + + &:hover { + form { + display: block; + } + + &>div { + display: none; + } + } + + input[type=text] { + padding: 2px; + } + } + + // The toolbar + .toolbar { + display: flex; + overflow: hidden; + overflow-y: auto; + padding: 0 12px 0 12px; + + // Give room for OS X scrollbar + white-space: nowrap; + z-index: 10000; + } + + // Fixed top + &.fixed-top { + bottom: auto; + top: 0; + + .tab { + bottom: auto; + top: 36px; + } + } + + // The toolbar preferences + #toolbar-position, + #toolbar-theme { + a { + padding: 0 6px; + display: inline-flex; + vertical-align: top; + + &:hover { + text-decoration: none; + } + } + } + + // The "Open/Close" toggle + #debug-bar-link { + bottom: 0; + display: inline-block; + font-size: $base-size; + line-height: 36px; + padding: 6px; + position: absolute; + right: 10px; + top: 0; + width: 24px; + } + + // The toolbar menus + .ci-label { + display: inline-flex; + font-size: $base-size - 2; + + &:hover { + cursor: pointer; + } + + a { + color: inherit; + display: flex; + letter-spacing: normal; + padding: 0 10px; + text-decoration: none; + align-items: center; + } + + // The toolbar icons + img { + margin: 6px 3px 6px 0; + width: 16px !important; + } + + // The toolbar notification badges + .badge { + @include border-radius(12px); + display: inline-block; + font-size: 75%; + font-weight: bold; + line-height: 12px; + margin-left: 5px; + padding: 2px 5px; + text-align: center; + vertical-align: baseline; + white-space: nowrap; + } + } + + // The tabs container + .tab { + bottom: 35px; + display: none; + left: 0; + max-height: 62%; + overflow: hidden; + overflow-y: auto; + padding: 1em 2em; + position: fixed; + right: 0; + z-index: 9999; + } + + // The "Timeline" tab + .timeline { + margin-left: 0; + width: 100%; + + th { + border-left: 1px solid; + font-size: $base-size - 4; + font-weight: 200; + padding: 5px 5px 10px 5px; + position: relative; + text-align: left; + + &:first-child { + border-left: 0; + } + } + + td { + border-left: 1px solid; + padding: 5px; + position: relative; + + &:first-child { + border-left: 0; + max-width: none; + } + + &.child-container { + padding: 0px; + + .timeline { + margin: 0px; + + td { + &:first-child { + &:not(.child-container) { + padding-left: calc(5px + 10px * var(--level)); + } + } + } + } + } + } + + .timer { + @include border-radius(4px); + display: inline-block; + padding: 5px; + position: absolute; + top: 30%; + } + + .timeline-parent { + cursor: pointer; + + td { + &:first-child { + nav { + background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMCAxNTAiPjxwYXRoIGQ9Ik02IDdoMThsLTkgMTV6bTAgMzBoMThsLTkgMTV6bTAgNDVoMThsLTktMTV6bTAgMzBoMThsLTktMTV6bTAgMTJsMTggMThtLTE4IDBsMTgtMTgiIGZpbGw9IiM1NTUiLz48cGF0aCBkPSJNNiAxMjZsMTggMThtLTE4IDBsMTgtMTgiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSIjNTU1Ii8+PC9zdmc+") no-repeat scroll 0 0/15px 75px transparent; + background-position: 0 25%; + display: inline-block; + height: 15px; + width: 15px; + margin-right: 3px; + vertical-align: middle; + } + } + } + } + + .timeline-parent-open { + background-color: #DFDFDF; + + td { + &:first-child { + nav { + background-position: 0 75%; + } + } + } + } + + .child-row { + &:hover { + background: transparent; + } + } + } + + // The "Routes" tab + .route-params, + .route-params-item { + vertical-align: top; + + td:first-child { + font-style: italic; + padding-left: 1em; + text-align: right; + } + } } @@ -409,21 +409,21 @@ // ========================================================================== */ .debug-view.show-view { - border: 1px solid; - margin: 4px; + border: 1px solid; + margin: 4px; } .debug-view-path { - font-family: monospace; - font-size: $base-size - 4; - letter-spacing: normal; - min-height: 16px; - padding: 2px; - text-align: left; + font-family: monospace; + font-size: $base-size - 4; + letter-spacing: normal; + min-height: 16px; + padding: 2px; + text-align: left; } .show-view .debug-view-path { - display: block !important; + display: block !important; } @@ -431,17 +431,17 @@ // ========================================================================== */ @media screen and (max-width: 1024px) { - #debug-bar { - .ci-label { - img { - margin: unset - } - } - } - - .hide-sm { - display: none !important; - } + #debug-bar { + .ci-label { + img { + margin: unset + } + } + } + + .hide-sm { + display: none !important; + } } @@ -453,22 +453,22 @@ // If the browser supports "prefers-color-scheme" and the scheme is "Dark" @media (prefers-color-scheme: dark) { - @import '_theme-dark'; + @import '_theme-dark'; } // If we force the "Dark" theme #toolbarContainer.dark { - @import '_theme-dark'; + @import '_theme-dark'; - td[data-debugbar-route] input[type=text] { - background: #000; - color: #fff; - } + td[data-debugbar-route] input[type=text] { + background: #000; + color: #fff; + } } // If we force the "Light" theme #toolbarContainer.light { - @import '_theme-light'; + @import '_theme-light'; } @@ -476,41 +476,41 @@ // ========================================================================== */ .debug-bar-width30 { - width: 30%; + width: 30%; } .debug-bar-width10 { - width: 10%; + width: 10%; } .debug-bar-width70p { - width: 70px; + width: 70px; } .debug-bar-width140p { - width: 140px; + width: 140px; } .debug-bar-width20e { - width: 20em; + width: 20em; } .debug-bar-width6r { - width: 6rem; + width: 6rem; } .debug-bar-ndisplay { - display: none; + display: none; } .debug-bar-alignRight { - text-align: right; + text-align: right; } .debug-bar-alignLeft { - text-align: left; + text-align: left; } .debug-bar-noverflow { - overflow: hidden; -} \ No newline at end of file + overflow: hidden; +} diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index bd080de2b1c8..f42cd2089027 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -1,8 +1,8 @@ /*! CodeIgniter 4 - Debug bar * ============================================================================ - * Forum: https://forum.codeigniter.com - * Github: https://github.com/codeigniter4/codeigniter4 - * Slack: https://codeigniterchat.slack.com + * Forum: https://forum.codeigniter.com + * Github: https://github.com/codeigniter4/codeigniter4 + * Slack: https://codeigniterchat.slack.com * Website: https://codeigniter.com */ #debug-icon { @@ -117,7 +117,6 @@ overflow: hidden; overflow-y: auto; padding: 0 12px 0 12px; - /* give room for OS X scrollbar */ white-space: nowrap; z-index: 10000; } #debug-bar.fixed-top { @@ -267,7 +266,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #debug-icon a:active, #debug-icon a:link, #debug-icon a:visited { + #debug-icon a:active, + #debug-icon a:link, + #debug-icon a:visited { color: #DD8615; } #debug-bar { @@ -353,7 +354,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #debug-icon a:active, #debug-icon a:link, #debug-icon a:visited { + #debug-icon a:active, + #debug-icon a:link, + #debug-icon a:visited { color: #DD8615; } #debug-bar { background-color: #252525; @@ -437,7 +440,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #toolbarContainer.dark #debug-icon a:active, #toolbarContainer.dark #debug-icon a:link, #toolbarContainer.dark #debug-icon a:visited { + #toolbarContainer.dark #debug-icon a:active, + #toolbarContainer.dark #debug-icon a:link, + #toolbarContainer.dark #debug-icon a:visited { color: #DD8615; } #toolbarContainer.dark #debug-bar { @@ -528,7 +533,9 @@ box-shadow: 0 0 4px #DFDFDF; -moz-box-shadow: 0 0 4px #DFDFDF; -webkit-box-shadow: 0 0 4px #DFDFDF; } - #toolbarContainer.light #debug-icon a:active, #toolbarContainer.light #debug-icon a:link, #toolbarContainer.light #debug-icon a:visited { + #toolbarContainer.light #debug-icon a:active, + #toolbarContainer.light #debug-icon a:link, + #toolbarContainer.light #debug-icon a:visited { color: #DD8615; } #toolbarContainer.light #debug-bar { From f0bae59b0cfcedf4af880fd091f7c3d085f4e30f Mon Sep 17 00:00:00 2001 From: Alex Schmitz Date: Wed, 7 Jul 2021 19:41:05 +0200 Subject: [PATCH 50/65] Replace file header. --- admin/css/debug-toolbar/toolbar.scss | 13 +++++++------ system/Debug/Toolbar/Views/toolbar.css | 13 +++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/admin/css/debug-toolbar/toolbar.scss b/admin/css/debug-toolbar/toolbar.scss index 0f07005ad9ab..c73510182ac7 100644 --- a/admin/css/debug-toolbar/toolbar.scss +++ b/admin/css/debug-toolbar/toolbar.scss @@ -1,9 +1,10 @@ -/*! CodeIgniter 4 - Debug bar - * ============================================================================ - * Forum: https://forum.codeigniter.com - * Github: https://github.com/codeigniter4/codeigniter4 - * Slack: https://codeigniterchat.slack.com - * Website: https://codeigniter.com +/** + * This file is part of the CodeIgniter 4 framework. + * + * (c) CodeIgniter Foundation + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ // IMPORTS diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index f42cd2089027..34b686385fd7 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -1,9 +1,10 @@ -/*! CodeIgniter 4 - Debug bar - * ============================================================================ - * Forum: https://forum.codeigniter.com - * Github: https://github.com/codeigniter4/codeigniter4 - * Slack: https://codeigniterchat.slack.com - * Website: https://codeigniter.com +/** + * This file is part of the CodeIgniter 4 framework. + * + * (c) CodeIgniter Foundation + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ #debug-icon { bottom: 0; From 0680f4f9a26dd153146f4ad215c054733835f9ad Mon Sep 17 00:00:00 2001 From: Alex Schmitz Date: Sun, 4 Jul 2021 18:22:15 +0200 Subject: [PATCH 51/65] Enable general benchmarking for filters. Fix copy paste. --- system/CodeIgniter.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 6af18d184606..c2c92b419727 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -374,7 +374,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache { $routeFilter = $this->tryToRouteIt($routes); - // Run "before" filters + // Start up the filters $filters = Services::filters(); // If any filters were specified within the routes file, @@ -388,7 +388,10 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache // Never run filters when running through Spark cli if (! defined('SPARKED')) { + // Run "before" filters + $this->benchmark->start('before_filters'); $possibleResponse = $filters->run($uri, 'before'); + $this->benchmark->stop('before_filters'); // If a ResponseInterface instance is returned then send it back to the client and stop if ($possibleResponse instanceof ResponseInterface) { @@ -427,8 +430,11 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache // Never run filters when running through Spark cli if (! defined('SPARKED')) { $filters->setResponse($this->response); + // Run "after" filters + $this->benchmark->start('after_filters'); $response = $filters->run($uri, 'after'); + $this->benchmark->stop('after_filters'); } else { $response = $this->response; From de55021980a9fd40f7b75d5cb0f2aaf950f278d4 Mon Sep 17 00:00:00 2001 From: MGatner Date: Thu, 15 Jul 2021 15:34:20 +0000 Subject: [PATCH 52/65] Simplify Action workflow --- .github/workflows/test-deptrac.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml index 9a9179b501d8..0698809319fe 100644 --- a/.github/workflows/test-deptrac.yml +++ b/.github/workflows/test-deptrac.yml @@ -26,12 +26,8 @@ on: jobs: build: - name: PHP ${{ matrix.php-versions }} Architectural Inspection + name: Architectural Inspection runs-on: ubuntu-20.04 - strategy: - fail-fast: false - matrix: - php-versions: ['7.4', '8.0'] steps: - name: Checkout uses: actions/checkout@v2 @@ -39,13 +35,10 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php-versions }} - tools: composer, pecl, phive, phpunit + php-version: '8.0' + tools: composer, phive extensions: intl, json, mbstring, gd, mysqlnd, xdebug, xml, sqlite3 - - name: Use latest Composer - run: composer self-update - - name: Validate composer.json run: composer validate --strict From f2ca69fe26aec21392f908d790d53eb9ac4a03fc Mon Sep 17 00:00:00 2001 From: MGatner Date: Thu, 15 Jul 2021 15:43:44 +0000 Subject: [PATCH 53/65] Implement transitive dependencies --- depfile.yaml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/depfile.yaml b/depfile.yaml index 147099379891..c1d8b87c3db2 100644 --- a/depfile.yaml +++ b/depfile.yaml @@ -186,12 +186,9 @@ ruleset: Publisher: - Files - URI - RESTful: # @todo Transitive Dependency - - API - - Controller - - Format - - HTTP - - Validation + RESTful: + - +API + - +Controller Router: - HTTP Security: From ac6cd3a787def0f438f84ffa49f17e99b1c5da8f Mon Sep 17 00:00:00 2001 From: "monken,wu" <610877102@mail.nknu.edu.tw> Date: Tue, 20 Jul 2021 02:13:39 +0800 Subject: [PATCH 54/65] fix entity-datamap guide wrong (#4937) --- system/Entity/Entity.php | 2 +- user_guide_src/source/models/entities.rst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index e077ea790297..cb279fd9881c 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -40,7 +40,7 @@ class Entity implements JsonSerializable * * Example: * $datamap = [ - * 'db_name' => 'class_name' + * 'class_name' => 'db_name' * ]; */ protected $datamap = []; diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 0d8ae47377bc..3b50c93d61c0 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -274,13 +274,13 @@ simply map the ``full_name`` column in the database to the ``$name`` property, a ]; protected $datamap = [ - 'full_name' => 'name', + 'name' => 'full_name', ]; } By adding our new database name to the ``$datamap`` array, we can tell the class what class property the database column -should be accessible through. The key of the array is the name of the column in the database, where the value in the array -is class property to map it to. +should be accessible through. The key of the array is class property to map it to, where the value in the array is the +name of the column in the database. In this example, when the model sets the ``full_name`` field on the User class, it actually assigns that value to the class' ``$name`` property, so it can be set and retrieved through ``$user->name``. The value will still be accessible From 2a90f33d61a58e79f8bb9e514d37bce58efe7095 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" <51850998+paulbalandan@users.noreply.github.com> Date: Fri, 23 Jul 2021 21:16:56 +0800 Subject: [PATCH 55/65] Fix splitting of string rules (#4957) --- system/Validation/Validation.php | 43 +++++++++++---- tests/system/Validation/ValidationTest.php | 64 ++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 6c96b7a1884a..6562eb896360 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -734,20 +734,41 @@ protected function getErrorMessage(string $rule, string $field, string $label = */ protected function splitRules(string $rules): array { - $nonEscapeBracket = '((?getPrivateMethodInvoker($this->validation, 'splitRules'); + $this->assertSame($expected, $splitter($input)); + } + + public function provideStringRulesCases(): iterable + { + yield [ + 'required', + ['required'], + ]; + + yield [ + 'required|numeric', + ['required', 'numeric'], + ]; + + yield [ + 'required|max_length[500]|hex', + ['required', 'max_length[500]', 'hex'], + ]; + + yield [ + 'required|numeric|regex_match[/[a-zA-Z]+/]', + ['required', 'numeric', 'regex_match[/[a-zA-Z]+/]'], + ]; + + yield [ + 'required|max_length[500]|regex_match[/^;"\'{}\[\]^<>=/]', + ['required', 'max_length[500]', 'regex_match[/^;"\'{}\[\]^<>=/]'], + ]; + + yield [ + 'regex_match[/^;"\'{}\[\]^<>=/]|regex_match[/[^a-z0-9.\|_]+/]', + ['regex_match[/^;"\'{}\[\]^<>=/]', 'regex_match[/[^a-z0-9.\|_]+/]'], + ]; + + yield [ + 'required|regex_match[/^(01[2689]|09)[0-9]{8}$/]|numeric', + ['required', 'regex_match[/^(01[2689]|09)[0-9]{8}$/]', 'numeric'], + ]; + + yield [ + 'required|regex_match[/^[0-9]{4}[\-\.\[\/][0-9]{2}[\-\.\[\/][0-9]{2}/]|max_length[10]', + ['required', 'regex_match[/^[0-9]{4}[\-\.\[\/][0-9]{2}[\-\.\[\/][0-9]{2}/]', 'max_length[10]'], + ]; + + yield [ + 'required|regex_match[/^(01|2689|09)[0-9]{8}$/]|numeric', + ['required', 'regex_match[/^(01|2689|09)[0-9]{8}$/]', 'numeric'], + ]; + } } From 5ec0fc7fb5ea6d057d69de7c4c091a58e3411929 Mon Sep 17 00:00:00 2001 From: Wolf Date: Mon, 2 Aug 2021 14:16:47 +0200 Subject: [PATCH 56/65] CLI: Prompt: Introduce promptByKey method --- system/CLI/CLI.php | 49 ++++++++++++++++++++--- user_guide_src/source/cli/cli_library.rst | 34 +++++++++++++++- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 89fd0da379da..bca5cb69814d 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -251,25 +251,64 @@ public static function prompt(string $field, $options = null, $validation = null if (empty($opts)) { $extraOutput = $extraOutputDefault; } else { - $extraOutput = ' [' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; - $validation[] = 'in_list[' . implode(',', $options) . ']'; + $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; + $validation[] = 'in_list[' . implode(', ', $options) . ']'; } $default = $options[0]; } - static::fwrite(STDOUT, $field . $extraOutput . ': '); + static::fwrite(STDOUT, $field . (trim($field) ? ' ' : '') . $extraOutput . ': '); // Read the input from keyboard. $input = trim(static::input()) ?: $default; if ($validation) { - while (! static::validate($field, $input, $validation)) { + while (! static::validate(trim($field), $input, $validation)) { $input = static::prompt($field, $options, $validation); } } - return empty($input) ? '' : $input; + return $input; + } + + /** + * prompt(), but based on the option's key + * + * @param array|string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value + * @param array|string|null $validation Validation rules + * + * @return string The selected key of $options + * + * @codeCoverageIgnore + */ + public static function promptByKey($text, array $options, $validation = null): string + { + if (is_string($text)) { + $text = [$text]; + } elseif (! is_array($text)) { + throw new InvalidArgumentException('$text can only be of type string|array'); + } + + if (! $options) { + throw new InvalidArgumentException('No options to select from were provided'); + } + + if ($line = array_shift($text)) { + CLI::write($line); + } + + // +2 for the square brackets around the key + $keyMaxLength = max(array_map('mb_strwidth', array_keys($options))) + 2; + + foreach ($options as $key => $description) { + $name = str_pad(' [' . $key . '] ', $keyMaxLength + 4, ' '); + CLI::write(CLI::color($name, 'green') . CLI::wrap($description, 125, $keyMaxLength + 4)); + } + + return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation); } //-------------------------------------------------------------------- diff --git a/user_guide_src/source/cli/cli_library.rst b/user_guide_src/source/cli/cli_library.rst index 1697c8409358..c81c17def224 100644 --- a/user_guide_src/source/cli/cli_library.rst +++ b/user_guide_src/source/cli/cli_library.rst @@ -38,7 +38,7 @@ Getting Input from the User Sometimes you need to ask the user for more information. They might not have provided optional command-line arguments, or the script may have encountered an existing file and needs confirmation before overwriting. This is -handled with the ``prompt()`` method. +handled with the ``prompt()`` or ``promptByKey()`` method. You can provide a question by passing it in as the first parameter:: @@ -61,6 +61,38 @@ Validation rules can also be written in the array syntax.:: $email = CLI::prompt('What is your email?', null, ['required', 'valid_email']); + +**promptByKey()** + +Predefined answers (options) for prompt sometimes need to be described or are too complex to select via their value. +``promptByKey()`` allows the user to select an option by its key instead of its value:: + + $fruit = CLI::promptByKey('These are your choices:', ['The red apple', 'The plump orange', 'The ripe banana']); + + //These are your choices: + // [0] The red apple + // [1] The plump orange + // [2] The ripe banana + // + //[0, 1, 2]: + +Named keys are also possible:: + + $fruit = CLI::promptByKey(['These are your choices:', 'Which would you like?'], [ + 'apple' => 'The red apple', + 'orange' => 'The plump orange', + 'banana' => 'The ripe banana' + ]); + + //These are your choices: + // [apple] The red apple + // [orange] The plump orange + // [banana] The ripe banana + // + //Which would you like? [apple, orange, banana]: + +Finally, you can pass :ref:`validation ` rules to the answer input as the third parameter, the acceptable answers are automatically restricted to the passed options. + Providing Feedback ================== From 11e4772f9486ffaeb11fff308ea5f3db6fc99308 Mon Sep 17 00:00:00 2001 From: Wolf Date: Mon, 2 Aug 2021 16:31:59 +0200 Subject: [PATCH 57/65] CLI: Prompt: Change color of default value to green --- system/CLI/CLI.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index bca5cb69814d..633d45d0c02e 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -238,13 +238,13 @@ public static function prompt(string $field, $options = null, $validation = null } if (is_string($options)) { - $extraOutput = ' [' . static::color($options, 'white') . ']'; + $extraOutput = ' [' . static::color($options, 'green') . ']'; $default = $options; } if (is_array($options) && $options) { $opts = $options; - $extraOutputDefault = static::color($opts[0], 'white'); + $extraOutputDefault = static::color($opts[0], 'green'); unset($opts[0]); From 6e4d5e754401b78015fd8d6cd806d3c66d38b49b Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" <51850998+paulbalandan@users.noreply.github.com> Date: Fri, 6 Aug 2021 21:07:03 +0800 Subject: [PATCH 58/65] Fix adding foreign keys with only string fields (#4988) --- system/Database/Forge.php | 19 ++++++++-- .../20160428212500_Create_test_tables.php | 2 - tests/system/Database/Live/ForgeTest.php | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/system/Database/Forge.php b/system/Database/Forge.php index 8ad39ae5a574..c95ced88c4d6 100644 --- a/system/Database/Forge.php +++ b/system/Database/Forge.php @@ -387,12 +387,25 @@ public function addField($field) throw new InvalidArgumentException('Field information is required for that operation.'); } - $this->fields[] = $field; + $fieldName = explode(' ', $field, 2)[0]; + $fieldName = trim($fieldName, '`\'"'); + + $this->fields[$fieldName] = $field; } } if (is_array($field)) { - $this->fields = array_merge($this->fields, $field); + foreach ($field as $idx => $f) { + if (is_string($f)) { + $this->addField($f); + + continue; + } + + if (is_array($f)) { + $this->fields = array_merge($this->fields, [$idx => $f]); + } + } } return $this; @@ -878,7 +891,7 @@ protected function _processFields(bool $createTable = false): array $fields = []; foreach ($this->fields as $key => $attributes) { - if (is_int($key) && ! is_array($attributes)) { + if (! is_array($attributes)) { $fields[] = ['_literal' => $attributes]; continue; diff --git a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php index 629550cbfbdd..e00e3e0c7390 100644 --- a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php +++ b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php @@ -128,7 +128,6 @@ public function up() 'data' => ['type' => 'BLOB', 'null' => false], ]); $this->forge->addKey('id', true); - $this->forge->addKey('timestamp'); $this->forge->createTable('ci_sessions', true); } @@ -140,7 +139,6 @@ public function up() "data bytea DEFAULT '' NOT NULL", ]); $this->forge->addKey('id', true); - $this->forge->addKey('timestamp'); $this->forge->createTable('ci_sessions', true); } } diff --git a/tests/system/Database/Live/ForgeTest.php b/tests/system/Database/Live/ForgeTest.php index 63681115f2ad..1eba35919068 100644 --- a/tests/system/Database/Live/ForgeTest.php +++ b/tests/system/Database/Live/ForgeTest.php @@ -415,6 +415,43 @@ public function testForeignKey() $this->forge->dropTable('forge_test_users', true); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4986 + */ + public function testForeignKeyAddingWithStringFields() + { + if ($this->db->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Testing only on MySQLi but fix expands to all DBs.'); + } + + $attributes = ['ENGINE' => 'InnoDB']; + + $this->forge->addField([ + '`id` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY', + '`name` VARCHAR(255) NOT NULL', + ])->createTable('forge_test_users', true, $attributes); + + $this->forge + ->addField([ + '`id` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY', + '`users_id` INT(11) NOT NULL', + '`name` VARCHAR(255) NOT NULL', + ]) + ->addForeignKey('users_id', 'forge_test_users', 'id', 'CASCADE', 'CASCADE') + ->createTable('forge_test_invoices', true, $attributes); + + $foreignKeyData = $this->db->getForeignKeyData('forge_test_invoices')[0]; + + $this->assertSame($this->db->DBPrefix . 'forge_test_invoices_users_id_foreign', $foreignKeyData->constraint_name); + $this->assertSame('users_id', $foreignKeyData->column_name); + $this->assertSame('id', $foreignKeyData->foreign_column_name); + $this->assertSame($this->db->DBPrefix . 'forge_test_invoices', $foreignKeyData->table_name); + $this->assertSame($this->db->DBPrefix . 'forge_test_users', $foreignKeyData->foreign_table_name); + + $this->forge->dropTable('forge_test_invoices', true); + $this->forge->dropTable('forge_test_users', true); + } + public function testForeignKeyFieldNotExistException() { $this->expectException(DatabaseException::class); From 14e5c32a60f34c49bf5cc0c9d4c6e1de646b2aa3 Mon Sep 17 00:00:00 2001 From: Wolf Wortmann Date: Mon, 2 Aug 2021 21:30:34 +0200 Subject: [PATCH 59/65] Validation: support placeholders for anything --- system/Validation/Validation.php | 77 +++++--- tests/system/Validation/ValidationTest.php | 200 ++++++++++++++++++++ user_guide_src/source/changelogs/index.rst | 1 + user_guide_src/source/changelogs/v4.2.0.rst | 10 + 4 files changed, 260 insertions(+), 28 deletions(-) create mode 100644 user_guide_src/source/changelogs/v4.2.0.rst diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 6562eb896360..e91a5c330238 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -17,6 +17,7 @@ use CodeIgniter\View\RendererInterface; use Config\Validation as ValidationConfig; use InvalidArgumentException; +use TypeError; /** * Validator @@ -353,23 +354,33 @@ public function withRequest(RequestInterface $request): ValidationInterface * 'rule' => 'message' * ] * - * @param string $field - * @param string|null $label - * @param string $rules - * @param array $errors + * @param string $field + * @param string|null $label + * @param string|array $rules + * @param array $errors * * @return $this + * + * @throws TypeError */ - public function setRule(string $field, string $label = null, string $rules, array $errors = []) + public function setRule(string $field, string $label = null, $rules, array $errors = []) { - $this->rules[$field] = [ - 'label' => $label, - 'rules' => $rules, + if (! is_array($rules) && ! is_string($rules)) { + throw new TypeError('$rules must be of type string|array'); + } + + $ruleSet = [ + $field => [ + 'label' => $label, + 'rules' => $rules, + ], ]; - $this->customErrors = array_merge($this->customErrors, [ - $field => $errors, - ]); + if ($errors) { + $ruleSet[$field]['errors'] = $errors; + } + + $this->setRules($ruleSet + $this->getRules()); return $this; } @@ -400,16 +411,18 @@ public function setRules(array $rules, array $errors = []): ValidationInterface $this->customErrors = $errors; foreach ($rules as $field => &$rule) { - if (! is_array($rule)) { - continue; - } + if (is_array($rule)) { + if (array_key_exists('errors', $rule)) { + $this->customErrors[$field] = $rule['errors']; + unset($rule['errors']); + } - if (! array_key_exists('errors', $rule)) { - continue; + // if $rule is already a rule collection, just move it to "rules" + // transforming [foo => [required, foobar]] to [foo => [rules => [required, foobar]]] + if (! array_key_exists('rules', $rule)) { + $rule = ['rules' => $rule]; + } } - - $this->customErrors[$field] = $rule['errors']; - unset($rule['errors']); } $this->rules = $rules; @@ -603,22 +616,30 @@ protected function fillPlaceholders(array $rules, array $data): array } if (! empty($replacements)) { - foreach ($rules as &$rule) { - if (is_array($rule)) { - foreach ($rule as &$row) { - // Should only be an `errors` array - // which doesn't take placeholders. - if (is_array($row)) { + foreach ($rules as &$validationSet) { + // Blast $rule apart, unless it's already an array + $plainRules = $validationSet['rules'] ?? $validationSet; + + if (is_array($plainRules)) { + foreach ($plainRules as &$row) { + // could be a named anonymous function or a callable + // both don't support placeholders + if (! is_string($row)) { continue; } $row = strtr($row, $replacements); } - - continue; + } else { + $plainRules = strtr($plainRules, $replacements); } - $rule = strtr($rule, $replacements); + // set rules together again + if (isset($validationSet['rules'])) { + $validationSet['rules'] = $plainRules; + } else { + $validationSet = $plainRules; + } } } diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 4fea757d9a21..5f885ad940a1 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -9,7 +9,9 @@ use CodeIgniter\Validation\Exceptions\ValidationException; use Config\App; use Config\Services; +use PHPUnit\Framework\ExpectationFailedException; use Tests\Support\Validation\TestRules; +use TypeError; /** * @internal @@ -87,6 +89,106 @@ public function testSetRulesStoresRules() $this->assertEquals($rules, $this->validation->getRules()); } + public function testSetRuleStoresRule() + { + $this->validation->setRules([]); + $this->validation->setRule('foo', null, 'bar|baz'); + + $this->assertSame([ + 'foo' => [ + 'label' => null, + 'rules' => 'bar|baz', + ], + ], $this->validation->getRules()); + } + + public function testSetRuleAddsRule() + { + $this->validation->setRules([ + 'bar' => [ + 'label' => null, + 'rules' => 'bar|baz', + ], + ]); + $this->validation->setRule('foo', null, 'foo|foz'); + + $this->assertSame([ + 'foo' => [ + 'label' => null, + 'rules' => 'foo|foz', + ], + 'bar' => [ + 'label' => null, + 'rules' => 'bar|baz', + ], + ], $this->validation->getRules()); + } + + public function testSetRuleOverwritesRule() + { + $this->validation->setRules([ + 'foo' => [ + 'label' => null, + 'rules' => 'bar|baz', + ], + ]); + $this->validation->setRule('foo', null, 'foo|foz'); + + $this->assertSame([ + 'foo' => [ + 'label' => null, + 'rules' => 'foo|foz', + ], + ], $this->validation->getRules()); + } + + /** + * @dataProvider setRuleRulesFormatCaseProvider + */ + public function testSetRuleRulesFormat(bool $expected, $rules): void + { + if (! $expected) { + $this->expectException(TypeError::class); + $this->expectExceptionMessage('$rules must be of type string|array'); + } + + $this->validation->setRule('foo', null, $rules); + $this->addToAssertionCount(1); + } + + public function setRuleRulesFormatCaseProvider(): iterable + { + yield 'fail-simple-object' => [ + false, + (object) ['required'], + ]; + + yield 'pass-single-string' => [ + true, + 'required', + ]; + + yield 'pass-single-array' => [ + true, + ['required'], + ]; + + yield 'fail-deep-object' => [ + false, + $this->validation, + ]; + + yield 'pass-multiple-string' => [ + true, + 'required|alpha', + ]; + + yield 'pass-multiple-array' => [ + true, + ['required', 'alpha'], + ]; + } + //-------------------------------------------------------------------- public function testRunReturnsFalseWithNothingToDo() @@ -1139,4 +1241,102 @@ public function provideStringRulesCases(): iterable ['required', 'regex_match[/^(01|2689|09)[0-9]{8}$/]', 'numeric'], ]; } + + /** + * internal method to simplify placeholder replacement test + * REQUIRES THE RULES TO BE SET FOR THE FIELD "foo" + * + * @param array|null $data optional POST data, needs to contain the key $placeholderField to pass + * + * @source https://github.com/codeigniter4/CodeIgniter4/pull/3910#issuecomment-784922913 + */ + private function placeholderReplacementResultDetermination(string $placeholder = 'id', ?array $data = null) + { + if ($data === null) { + $data = [$placeholder => 'placeholder-value']; + } + + $validationRules = $this->getPrivateMethodInvoker($this->validation, 'fillPlaceholders')($this->validation->getRules(), $data); + $fieldRules = $validationRules['foo']['rules'] ?? $validationRules['foo']; + if (is_string($fieldRules)) { + $fieldRules = $this->getPrivateMethodInvoker($this->validation, 'splitRules')($fieldRules); + } + + // loop all rules for this field + foreach ($fieldRules as $rule) { + // only string type rules are supported + if (is_string($rule)) { + $this->assertStringNotContainsString('{' . $placeholder . '}', $rule); + } + } + } + + /** + * @see ValidationTest::placeholderReplacementResultDetermination() + */ + public function testPlaceholderReplacementTestFails() + { + // to test if placeholderReplacementResultDetermination() works we provoke and expect an exception + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Failed asserting that \'filter[{id}]\' does not contain "{id}".'); + + $this->validation->setRule('foo', 'foo-label', 'required|filter[{id}]'); + + // calling with empty $data should produce an exception since {id} can't be replaced + $this->placeholderReplacementResultDetermination('id', []); + } + + public function testPlaceholderReplacementSetSingleRuleString() + { + $this->validation->setRule('foo', null, 'required|filter[{id}]'); + + $this->placeholderReplacementResultDetermination(); + } + + public function testPlaceholderReplacementSetSingleRuleArray() + { + $this->validation->setRule('foo', null, ['required', 'filter[{id}]']); + + $this->placeholderReplacementResultDetermination(); + } + + public function testPlaceholderReplacementSetMultipleRulesSimpleString() + { + $this->validation->setRules([ + 'foo' => 'required|filter[{id}]', + ]); + + $this->placeholderReplacementResultDetermination(); + } + + public function testPlaceholderReplacementSetMultipleRulesSimpleArray() + { + $this->validation->setRules([ + 'foo' => ['required', 'filter[{id}]'], + ]); + + $this->placeholderReplacementResultDetermination(); + } + + public function testPlaceholderReplacementSetMultipleRulesComplexString() + { + $this->validation->setRules([ + 'foo' => [ + 'rules' => 'required|filter[{id}]', + ], + ]); + + $this->placeholderReplacementResultDetermination(); + } + + public function testPlaceholderReplacementSetMultipleRulesComplexArray() + { + $this->validation->setRules([ + 'foo' => [ + 'rules' => ['required', 'filter[{id}]'], + ], + ]); + + $this->placeholderReplacementResultDetermination(); + } } diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index 038d259e4189..a997a57f0c3f 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.2.0 v4.1.4 v4.1.3 v4.1.2 diff --git a/user_guide_src/source/changelogs/v4.2.0.rst b/user_guide_src/source/changelogs/v4.2.0.rst new file mode 100644 index 000000000000..4043e0d2e531 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.2.0.rst @@ -0,0 +1,10 @@ +Version 4.2.0 +============= + +Release Date: + +**4.2.0 release of CodeIgniter4** + +Changes: + +- ``Validation::setRule`` signature changed and now allows string or array types for ``$rules``; see PR #3910 From 0339f0a0c92cea6047a68055777a0e44421bcf41 Mon Sep 17 00:00:00 2001 From: Paulo Esteves Date: Sun, 15 Aug 2021 00:27:10 +0100 Subject: [PATCH 60/65] Fix lang() function is overriding locale --- system/Common.php | 21 +++++++++++++++++++-- tests/system/Language/LanguageTest.php | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/system/Common.php b/system/Common.php index 77b8adcffbb0..9a5f50b2635b 100644 --- a/system/Common.php +++ b/system/Common.php @@ -763,8 +763,25 @@ function is_really_writable(string $file): bool */ function lang(string $line, array $args = [], string $locale = null) { - return Services::language($locale) - ->getLine($line, $args); + $language = Services::language(); + + //Get active locale + $activeLocale = $language->getLocale(); + + if ($locale && $locale != $activeLocale) + { + $language->setLocale($locale); + } + + $line = $language->getLine($line, $args); + + if ($locale && $locale != $activeLocale) + { + //Reset to active locale + $language->setLocale($activeLocale); + } + + return $line; } } diff --git a/tests/system/Language/LanguageTest.php b/tests/system/Language/LanguageTest.php index 06152922c3a2..a3a39073acb9 100644 --- a/tests/system/Language/LanguageTest.php +++ b/tests/system/Language/LanguageTest.php @@ -288,6 +288,25 @@ public function testBaseFallbacks() //-------------------------------------------------------------------- + /** + * Test if after using lang() with a locale the Language class keep the locale after return the $line + */ + public function testLangKeepLocale() + { + $this->lang = Services::language('en', true); + + lang('Language.languageGetLineInvalidArgumentException'); + $this->assertEquals('en', $this->lang->getLocale()); + + lang('Language.languageGetLineInvalidArgumentException', [], 'ru'); + $this->assertEquals('en', $this->lang->getLocale()); + + lang('Language.languageGetLineInvalidArgumentException'); + $this->assertEquals('en', $this->lang->getLocale()); + } + + //-------------------------------------------------------------------- + /** * Testing base locale vs variants, with fallback to English. * From 8e16ffc7f88aa727c0965bb47dc3a78e1c096471 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 17 Aug 2021 14:53:13 +0000 Subject: [PATCH 61/65] Add config for cache keys --- app/Config/Cache.php | 15 +++++++++++++++ system/Cache/Handlers/BaseHandler.php | 10 +++++++--- .../system/Cache/Handlers/BaseHandlerTest.php | 10 ++++++++++ user_guide_src/source/changelogs/index.rst | 1 + user_guide_src/source/changelogs/v4.2.0.rst | 18 ++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 user_guide_src/source/changelogs/v4.2.0.rst diff --git a/app/Config/Cache.php b/app/Config/Cache.php index c82e3cbe646d..6d983bc6131c 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -2,6 +2,7 @@ namespace Config; +use CodeIgniter\Cache\Handlers\BaseHandler; use CodeIgniter\Cache\Handlers\DummyHandler; use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\Cache\Handlers\MemcachedHandler; @@ -97,6 +98,20 @@ class Cache extends BaseConfig */ public $ttl = 60; + /** + * -------------------------------------------------------------------------- + * Reserved Characters + * -------------------------------------------------------------------------- + * + * A string of reserved characters that will not be allowed in keys or tags. + * Strings that violate this restriction will cause handlers to throw. + * Default: {}()/\@: + * Note: The default set is required for PSR-6 compliance. + * + * @var string + */ + public $reservedCharacters = '{}()/\@:'; + /** * -------------------------------------------------------------------------- * File settings diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index ffd031b00134..340100e1838c 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -22,8 +22,10 @@ abstract class BaseHandler implements CacheInterface { /** - * Reserved characters that cannot be used in a key or tag. + * Reserved characters that cannot be used in a key or tag. May be overridden by the config. * From https://github.com/symfony/cache-contracts/blob/c0446463729b89dd4fa62e9aeecc80287323615d/ItemInterface.php#L43 + * + * @deprecated in favor of the Cache config */ public const RESERVED_CHARACTERS = '{}()/\@:'; @@ -58,8 +60,10 @@ public static function validateKey($key, $prefix = ''): string if ($key === '') { throw new InvalidArgumentException('Cache key cannot be empty.'); } - if (strpbrk($key, self::RESERVED_CHARACTERS) !== false) { - throw new InvalidArgumentException('Cache key contains reserved characters ' . self::RESERVED_CHARACTERS); + + $reserved = config('Cache')->reservedCharacters ?? self::RESERVED_CHARACTERS; + if ($reserved && strpbrk($key, $reserved) !== false) { + throw new InvalidArgumentException('Cache key contains reserved characters ' . $reserved); } // If the key with prefix exceeds the length then return the hashed version diff --git a/tests/system/Cache/Handlers/BaseHandlerTest.php b/tests/system/Cache/Handlers/BaseHandlerTest.php index 133c644f1b9e..aa95da40e83e 100644 --- a/tests/system/Cache/Handlers/BaseHandlerTest.php +++ b/tests/system/Cache/Handlers/BaseHandlerTest.php @@ -30,6 +30,16 @@ public function invalidTypeProvider(): array ]; } + public function testValidateKeyUsesConfig() + { + config('Cache')->reservedCharacters = 'b'; + + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Cache key contains reserved characters b'); + + BaseHandler::validateKey('banana'); + } + public function testValidateKeySuccess() { $string = 'banana'; diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index 038d259e4189..a997a57f0c3f 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.2.0 v4.1.4 v4.1.3 v4.1.2 diff --git a/user_guide_src/source/changelogs/v4.2.0.rst b/user_guide_src/source/changelogs/v4.2.0.rst new file mode 100644 index 000000000000..c1a7d2673ab1 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.2.0.rst @@ -0,0 +1,18 @@ +Version 4.2.0 +============= + +Release Date: Not released + +**4.2.0 release of CodeIgniter4** + +Enhancements: + +- Added Cache config for reserved characters + +Changes: + +Deprecations: + +- Deprecated ``CodeIgniter\\Cache\\Handlers\\BaseHandler::RESERVED_CHARACTERS`` in favor of the new config property + +Bugs Fixed: From 2ec6e002dd0574ea23855aa584c45dd687c7d2bd Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 4 Jul 2021 10:20:59 +0200 Subject: [PATCH 62/65] Fix bug with handling boolean values in set() method in BaseBuilder and Model class --- system/Database/BaseBuilder.php | 4 +- system/Database/SQLSRV/Forge.php | 4 + system/Database/SQLite3/Forge.php | 4 + system/Model.php | 8 +- .../20160428212500_Create_test_tables.php | 1 + .../_support/Database/Seeds/CITestSeeder.php | 4 +- tests/system/Database/Builder/UpdateTest.php | 74 +++++++++++++++++++ tests/system/Database/Live/UpdateTest.php | 19 +++++ .../source/installation/upgrade_420.rst | 12 +++ 9 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 user_guide_src/source/installation/upgrade_420.rst diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 71bb38a03d1d..67eae76d05fa 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -1673,12 +1673,12 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string * Allows key/value pairs to be set for insert(), update() or replace(). * * @param string|array|object $key Field name, or an array of field/value pairs - * @param string $value Field value, if $key is a single field + * @param mixed $value Field value, if $key is a single field * @param bool $escape Whether to escape values and identifiers * * @return $this */ - public function set($key, ?string $value = '', bool $escape = null) + public function set($key, $value = '', bool $escape = null) { $key = $this->objectToArray($key); diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php index 185bb1663c63..28545d567427 100755 --- a/system/Database/SQLSRV/Forge.php +++ b/system/Database/SQLSRV/Forge.php @@ -401,6 +401,10 @@ protected function _attributeType(array &$attributes) $attributes['TYPE'] = 'DATETIME'; break; + case 'BOOLEAN': + $attributes['TYPE'] = 'BIT'; + break; + default: break; } diff --git a/system/Database/SQLite3/Forge.php b/system/Database/SQLite3/Forge.php index 3edc293ed481..598483f192b0 100644 --- a/system/Database/SQLite3/Forge.php +++ b/system/Database/SQLite3/Forge.php @@ -233,6 +233,10 @@ protected function _attributeType(array &$attributes) $attributes['TYPE'] = 'TEXT'; break; + case 'BOOLEAN': + $attributes['TYPE'] = 'INT'; + break; + default: break; } diff --git a/system/Model.php b/system/Model.php index 1994373351b2..6845a3f63698 100644 --- a/system/Model.php +++ b/system/Model.php @@ -579,13 +579,13 @@ public function builder(?string $table = null) * data here. This allows it to be used with any of the other * builder methods and still get validated data, like replace. * - * @param mixed $key Field name, or an array of field/value pairs - * @param string|null $value Field value, if $key is a single field - * @param bool|null $escape Whether to escape values and identifiers + * @param mixed $key Field name, or an array of field/value pairs + * @param mixed $value Field value, if $key is a single field + * @param bool|null $escape Whether to escape values and identifiers * * @return $this */ - public function set($key, ?string $value = '', ?bool $escape = null) + public function set($key, $value = '', ?bool $escape = null) { $data = is_array($key) ? $key : [$key => $value]; diff --git a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php index 629550cbfbdd..5ef2a009bafa 100644 --- a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php +++ b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php @@ -61,6 +61,7 @@ public function up() 'type_double' => ['type' => 'DOUBLE', 'null' => true], 'type_decimal' => ['type' => 'DECIMAL', 'constraint' => '18,4', 'null' => true], 'type_blob' => ['type' => 'BLOB', 'null' => true], + 'type_boolean' => ['type' => 'BOOLEAN', 'null' => true], ]; if ($this->db->DBDriver === 'Postgre') { diff --git a/tests/_support/Database/Seeds/CITestSeeder.php b/tests/_support/Database/Seeds/CITestSeeder.php index e633543414f7..e7cd626efdda 100644 --- a/tests/_support/Database/Seeds/CITestSeeder.php +++ b/tests/_support/Database/Seeds/CITestSeeder.php @@ -97,6 +97,7 @@ public function run() 'type_datetime' => '2020-06-18T05:12:24.000+02:00', 'type_timestamp' => '2019-07-18T21:53:21.000+02:00', 'type_bigint' => 2342342, + 'type_boolean' => 1, ], ], ]; @@ -110,7 +111,8 @@ public function run() } if ($this->db->DBDriver === 'Postgre') { - $data['type_test'][0]['type_time'] = '15:22:00'; + $data['type_test'][0]['type_time'] = '15:22:00'; + $data['type_test'][0]['type_boolean'] = true; unset( $data['type_test'][0]['type_enum'], $data['type_test'][0]['type_set'], diff --git a/tests/system/Database/Builder/UpdateTest.php b/tests/system/Database/Builder/UpdateTest.php index ea2fdf8412c2..e1a527ec6f0f 100644 --- a/tests/system/Database/Builder/UpdateTest.php +++ b/tests/system/Database/Builder/UpdateTest.php @@ -85,6 +85,80 @@ public function testUpdateWithSet() $this->assertSame($expectedBinds, $builder->getBinds()); } + public function testUpdateWithSetAsInt() + { + $builder = new BaseBuilder('jobs', $this->db); + + $builder->testMode()->set('age', 22)->where('id', 1)->update(null, null, null); + + $expectedSQL = 'UPDATE "jobs" SET "age" = 22 WHERE "id" = 1'; + $expectedBinds = [ + 'age' => [ + 22, + true, + ], + 'id' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testUpdateWithSetAsBoolean() + { + $builder = new BaseBuilder('jobs', $this->db); + + $builder->testMode()->set('manager', true)->where('id', 1)->update(null, null, null); + + $expectedSQL = 'UPDATE "jobs" SET "manager" = 1 WHERE "id" = 1'; + $expectedBinds = [ + 'manager' => [ + true, + true, + ], + 'id' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testUpdateWithSetAsArray() + { + $builder = new BaseBuilder('jobs', $this->db); + + $builder->testMode()->set(['name' => 'Programmer', 'age' => 22, 'manager' => true])->where('id', 1)->update(null, null, null); + + $expectedSQL = 'UPDATE "jobs" SET "name" = \'Programmer\', "age" = 22, "manager" = 1 WHERE "id" = 1'; + $expectedBinds = [ + 'name' => [ + 'Programmer', + true, + ], + 'age' => [ + 22, + true, + ], + 'manager' => [ + true, + true, + ], + 'id' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + public function testUpdateThrowsExceptionWithNoData() { $builder = new BaseBuilder('jobs', $this->db); diff --git a/tests/system/Database/Live/UpdateTest.php b/tests/system/Database/Live/UpdateTest.php index f7c94d96dea9..334d6f6c5d59 100644 --- a/tests/system/Database/Live/UpdateTest.php +++ b/tests/system/Database/Live/UpdateTest.php @@ -230,4 +230,23 @@ public function testSetWithoutEscape() 'description' => 'Developer', ]); } + + public function testSetWithBoolean() + { + $this->db->table('type_test') + ->set('type_boolean', false) + ->update(); + + $this->seeInDatabase('type_test', [ + 'type_boolean' => false, + ]); + + $this->db->table('type_test') + ->set('type_boolean', true) + ->update(); + + $this->seeInDatabase('type_test', [ + 'type_boolean' => true, + ]); + } } diff --git a/user_guide_src/source/installation/upgrade_420.rst b/user_guide_src/source/installation/upgrade_420.rst new file mode 100644 index 000000000000..a306b4978347 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_420.rst @@ -0,0 +1,12 @@ +############################# +Upgrading from 4.1.3 to 4.2.0 +############################# + +**Changes for set() method in BaseBuilder and Model class** + +The casting for the ``$value`` parameter has been removed to fix a bug where passing parameters as array and string +to the ``set()`` method were handled differently. If you extended the ``BaseBuilder`` class or ``Model`` class yourself +and modified the ``set()`` method, then you need to change its definition from +``public function set($key, ?string $value = '', ?bool $escape = null)`` to +``public function set($key, $value = '', ?bool $escape = null)``. + From 02ba1d61f81340c9a755c02efc803546c7ef0eac Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 4 Jul 2021 10:31:25 +0200 Subject: [PATCH 63/65] Update method definition in the user guide --- user_guide_src/source/database/query_builder.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index add336c51081..411abf002f34 100755 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -1727,7 +1727,7 @@ Class Reference .. php:method:: set($key[, $value = ''[, $escape = null]]) :param mixed $key: Field name, or an array of field/value pairs - :param string $value: Field value, if $key is a single field + :param mixed $value: Field value, if $key is a single field :param bool $escape: Whether to escape values and identifiers :returns: ``BaseBuilder`` instance (method chaining) :rtype: ``BaseBuilder`` From 4cdaca646f23e0a2e703166d1d6efed9f17c1a9b Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 23 Jul 2021 09:53:19 +0200 Subject: [PATCH 64/65] add to toctree --- user_guide_src/source/changelogs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index 038d259e4189..a997a57f0c3f 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.2.0 v4.1.4 v4.1.3 v4.1.2 From d2fe2710dc83fd4608e5e005f3a41ecd10925daf Mon Sep 17 00:00:00 2001 From: MGatner Date: Wed, 18 Aug 2021 07:49:41 -0400 Subject: [PATCH 65/65] Update app/Config/Cache.php --- app/Config/Cache.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Config/Cache.php b/app/Config/Cache.php index 6d983bc6131c..7dbe30f37b52 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -2,7 +2,6 @@ namespace Config; -use CodeIgniter\Cache\Handlers\BaseHandler; use CodeIgniter\Cache\Handlers\DummyHandler; use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\Cache\Handlers\MemcachedHandler;