From c283d1a290588d8c513d006771c1c910b424ef6e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 25 Feb 2025 13:58:12 +0100 Subject: [PATCH 01/84] docs: add changelog and upgrade for v4.7.0 (#9464) --- user_guide_src/source/changelogs/index.rst | 1 + user_guide_src/source/changelogs/v4.7.0.rst | 84 +++++++++++++++++++ .../source/installation/upgrade_470.rst | 55 ++++++++++++ .../source/installation/upgrading.rst | 1 + 4 files changed, 141 insertions(+) create mode 100644 user_guide_src/source/changelogs/v4.7.0.rst create mode 100644 user_guide_src/source/installation/upgrade_470.rst diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index ee417164aa87..252d7943bdc2 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.7.0 v4.6.1 v4.6.0 v4.5.8 diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst new file mode 100644 index 000000000000..06d2a9a8ce39 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -0,0 +1,84 @@ +############# +Version 4.7.0 +############# + +Release Date: Unreleased + +**4.7.0 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 3 + +********** +Highlights +********** + +- TBD + +******** +BREAKING +******** + +Behavior Changes +================ + +Interface Changes +================= + +Method Signature Changes +======================== + +************ +Enhancements +************ + +Commands +======== + +Testing +======= + +Database +======== + +Query Builder +------------- + +Forge +----- + +Others +------ + +Model +===== + +Libraries +========= + +Helpers and Functions +===================== + +Others +====== + +*************** +Message Changes +*************** + +******* +Changes +******* + +************ +Deprecations +************ + +********** +Bugs Fixed +********** + +See the repo's +`CHANGELOG.md `_ +for a complete list of bugs fixed. diff --git a/user_guide_src/source/installation/upgrade_470.rst b/user_guide_src/source/installation/upgrade_470.rst new file mode 100644 index 000000000000..2882870a18f4 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_470.rst @@ -0,0 +1,55 @@ +############################# +Upgrading from 4.6.x to 4.7.0 +############################# + +Please refer to the upgrade instructions corresponding to your installation method. + +- :ref:`Composer Installation App Starter Upgrading ` +- :ref:`Composer Installation Adding CodeIgniter4 to an Existing Project Upgrading ` +- :ref:`Manual Installation Upgrading ` + +.. contents:: + :local: + :depth: 2 + +********************** +Mandatory File Changes +********************** + +**************** +Breaking Changes +**************** + +********************* +Breaking Enhancements +********************* + +************* +Project Files +************* + +Some files in the **project space** (root, app, public, writable) received updates. Due to +these files being outside of the **system** scope they will not be changed without your intervention. + +.. note:: There are some third-party CodeIgniter modules available to assist + with merging changes to the project space: + `Explore on Packagist `_. + +Content Changes +=============== + +The following files received significant changes (including deprecations or visual adjustments) +and it is recommended that you merge the updated versions with your application: + +Config +------ + +- @TODO + +All Changes +=========== + +This is a list of all files in the **project space** that received changes; +many will be simple comments or formatting that have no effect on the runtime: + +- @TODO diff --git a/user_guide_src/source/installation/upgrading.rst b/user_guide_src/source/installation/upgrading.rst index a2125cba81a3..6053d5a036fb 100644 --- a/user_guide_src/source/installation/upgrading.rst +++ b/user_guide_src/source/installation/upgrading.rst @@ -16,6 +16,7 @@ See also :doc:`./backward_compatibility_notes`. backward_compatibility_notes + upgrade_470 upgrade_461 upgrade_460 upgrade_458 From b1f2bee6ba3e530d5c0f1963242f2f2cbd65a4fa Mon Sep 17 00:00:00 2001 From: ip-qi <57226580+ip-qi@users.noreply.github.com> Date: Mon, 3 Mar 2025 08:08:12 +0000 Subject: [PATCH 02/84] feat: add email/smtp plain auth method (#9462) * -revert: auto corrected (composer cs-fix) comments and auto added function return types -comment: add depreceation comment for failedSMTPLogin email error message -refactor: improve authorization validation flow and email error response messages -added $SMTPAuthMethod in Config\Email file with default value of login * Update system/Email/Email.php apply suggestion for declaring string type of $SMTPAuthMethod Co-authored-by: John Paul E. Balandan, CPA * Update user_guide_src/source/libraries/email.rst Apply Suggestion for description of SMTPAuthMethod Co-authored-by: Michal Sniatala * docs: update changelog * fix: php-cs violations * Update user_guide_src/source/changelogs/v4.7.0.rst Apply suggestions for changelog Co-authored-by: Michal Sniatala --------- Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: Michal Sniatala --- app/Config/Email.php | 5 ++ system/Email/Email.php | 56 ++++++++++++++++----- system/Language/en/Email.php | 42 +++++++++------- user_guide_src/source/changelogs/v4.7.0.rst | 5 ++ user_guide_src/source/libraries/email.rst | 3 +- 5 files changed, 79 insertions(+), 32 deletions(-) diff --git a/app/Config/Email.php b/app/Config/Email.php index 4dce650b32ec..77c573ad3acb 100644 --- a/app/Config/Email.php +++ b/app/Config/Email.php @@ -30,6 +30,11 @@ class Email extends BaseConfig */ public string $SMTPHost = ''; + /** + * Which SMTP authentication method to use: login, plain + */ + public string $SMTPAuthMethod = 'login'; + /** * SMTP Username */ diff --git a/system/Email/Email.php b/system/Email/Email.php index 24c3af4cd376..d5a8cc525ba1 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -279,6 +279,11 @@ class Email */ protected $SMTPAuth = false; + /** + * Which SMTP authentication method to use: login, plain + */ + protected string $SMTPAuthMethod = 'login'; + /** * Whether to send a Reply-To header * @@ -2019,45 +2024,72 @@ protected function SMTPAuthenticate() return true; } - if ($this->SMTPUser === '' && $this->SMTPPass === '') { + // If no username or password is set + if ($this->SMTPUser === '' || $this->SMTPPass === '') { $this->setErrorMessage(lang('Email.noSMTPAuth')); return false; } - $this->sendData('AUTH LOGIN'); + // normalize in case user entered capital words LOGIN/PLAIN + $this->SMTPAuthMethod = strtolower($this->SMTPAuthMethod); + + // Validate supported authentication methods + $validMethods = ['login', 'plain']; + if (! in_array($this->SMTPAuthMethod, $validMethods, true)) { + $this->setErrorMessage(lang('Email.invalidSMTPAuthMethod', [$this->SMTPAuthMethod])); + + return false; + } + + // send initial 'AUTH' command + $this->sendData('AUTH ' . strtoupper($this->SMTPAuthMethod)); $reply = $this->getSMTPData(); if (str_starts_with($reply, '503')) { // Already authenticated return true; } + // if 'AUTH' command is unsuported by the server if (! str_starts_with($reply, '334')) { - $this->setErrorMessage(lang('Email.failedSMTPLogin', [$reply])); + $this->setErrorMessage(lang('Email.failureSMTPAuthMethod', [strtoupper($this->SMTPAuthMethod)])); return false; } - $this->sendData(base64_encode($this->SMTPUser)); - $reply = $this->getSMTPData(); + switch ($this->SMTPAuthMethod) { + case 'login': + $this->sendData(base64_encode($this->SMTPUser)); + $reply = $this->getSMTPData(); - if (! str_starts_with($reply, '334')) { - $this->setErrorMessage(lang('Email.SMTPAuthUsername', [$reply])); + if (! str_starts_with($reply, '334')) { + $this->setErrorMessage(lang('Email.SMTPAuthUsername', [$reply])); - return false; + return false; + } + + $this->sendData(base64_encode($this->SMTPPass)); + break; + + case 'plain': + // send credentials as the single second command + $authString = "\0" . $this->SMTPUser . "\0" . $this->SMTPPass; + + $this->sendData(base64_encode($authString)); + break; } - $this->sendData(base64_encode($this->SMTPPass)); $reply = $this->getSMTPData(); + if (! str_starts_with($reply, '235')) { // Authentication failed + $errorMessage = $this->SMTPAuthMethod === 'plain' ? 'Email.SMTPAuthCredentials' : 'Email.SMTPAuthPassword'; - if (! str_starts_with($reply, '235')) { - $this->setErrorMessage(lang('Email.SMTPAuthPassword', [$reply])); + $this->setErrorMessage(lang($errorMessage, [$reply])); return false; } if ($this->SMTPKeepAlive) { - $this->SMTPAuth = false; + $this->SMTPAuth = false; // Prevent re-authentication for keep-alive sessions } return true; diff --git a/system/Language/en/Email.php b/system/Language/en/Email.php index 23a97a75f23c..44d4c03cae3e 100644 --- a/system/Language/en/Email.php +++ b/system/Language/en/Email.php @@ -13,23 +13,27 @@ // Email language settings return [ - 'mustBeArray' => 'The email validation method must be passed an array.', - 'invalidAddress' => 'Invalid email address: "{0}"', - 'attachmentMissing' => 'Unable to locate the following email attachment: "{0}"', - 'attachmentUnreadable' => 'Unable to open this attachment: "{0}"', - 'noFrom' => 'Cannot send mail with no "From" header.', - 'noRecipients' => 'You must include recipients: To, Cc, or Bcc', - 'sendFailurePHPMail' => 'Unable to send email using PHP mail(). Your server might not be configured to send mail using this method.', - 'sendFailureSendmail' => 'Unable to send email using Sendmail. Your server might not be configured to send mail using this method.', - 'sendFailureSmtp' => 'Unable to send email using SMTP. Your server might not be configured to send mail using this method.', - 'sent' => 'Your message has been successfully sent using the following protocol: {0}', - 'noSocket' => 'Unable to open a socket to Sendmail. Please check settings.', - 'noHostname' => 'You did not specify a SMTP hostname.', - 'SMTPError' => 'The following SMTP error was encountered: {0}', - 'noSMTPAuth' => 'Error: You must assign an SMTP username and password.', - 'failedSMTPLogin' => 'Failed to send AUTH LOGIN command. Error: {0}', - 'SMTPAuthUsername' => 'Failed to authenticate username. Error: {0}', - 'SMTPAuthPassword' => 'Failed to authenticate password. Error: {0}', - 'SMTPDataFailure' => 'Unable to send data: {0}', - 'exitStatus' => 'Exit status code: {0}', + 'mustBeArray' => 'The email validation method must be passed an array.', + 'invalidAddress' => 'Invalid email address: "{0}"', + 'attachmentMissing' => 'Unable to locate the following email attachment: "{0}"', + 'attachmentUnreadable' => 'Unable to open this attachment: "{0}"', + 'noFrom' => 'Cannot send mail with no "From" header.', + 'noRecipients' => 'You must include recipients: To, Cc, or Bcc', + 'sendFailurePHPMail' => 'Unable to send email using PHP mail(). Your server might not be configured to send mail using this method.', + 'sendFailureSendmail' => 'Unable to send email using Sendmail. Your server might not be configured to send mail using this method.', + 'sendFailureSmtp' => 'Unable to send email using SMTP. Your server might not be configured to send mail using this method.', + 'sent' => 'Your message has been successfully sent using the following protocol: {0}', + 'noSocket' => 'Unable to open a socket to Sendmail. Please check settings.', + 'noHostname' => 'You did not specify a SMTP hostname.', + 'SMTPError' => 'The following SMTP error was encountered: {0}', + 'noSMTPAuth' => 'Error: You must assign an SMTP username and password.', + 'invalidSMTPAuthMethod' => 'Error: SMTP authorization method "{0}" is not supported in codeigniter, set either "login" or "plain" authorization method', + 'failureSMTPAuthMethod' => 'Unable to initiate AUTH command. Your server might not be configured to use AUTH {0} authentication method.', + 'SMTPAuthCredentials' => 'Failed to authenticate user credentials. Error: {0}', + 'SMTPAuthUsername' => 'Failed to authenticate username. Error: {0}', + 'SMTPAuthPassword' => 'Failed to authenticate password. Error: {0}', + 'SMTPDataFailure' => 'Unable to send data: {0}', + 'exitStatus' => 'Exit status code: {0}', + // @deprecated + 'failedSMTPLogin' => 'Failed to send AUTH LOGIN command. Error: {0}', ]; diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 06d2a9a8ce39..ae6797af7884 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -57,6 +57,8 @@ Model Libraries ========= +**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option + Helpers and Functions ===================== @@ -67,6 +69,9 @@ Others Message Changes *************** +- Added ``Email.invalidSMTPAuthMethod`` and ``Email.failureSMTPAuthMethod`` +- Deprecated ``Email.failedSMTPLogin`` + ******* Changes ******* diff --git a/user_guide_src/source/libraries/email.rst b/user_guide_src/source/libraries/email.rst index ec21b79ddcf2..b89d764e50f9 100644 --- a/user_guide_src/source/libraries/email.rst +++ b/user_guide_src/source/libraries/email.rst @@ -39,7 +39,7 @@ Here is a basic example demonstrating how you might send email: Setting Email Preferences ========================= -There are 21 different preferences available to tailor how your email +There are 22 different preferences available to tailor how your email messages are sent. You can either set them manually as described here, or automatically via preferences stored in your config file, described in `Email Preferences`_. @@ -120,6 +120,7 @@ Preference Default Value Options Description or ``smtp`` **mailPath** /usr/sbin/sendmail The server path to Sendmail. **SMTPHost** SMTP Server Hostname. +**SMTPAuthMethod** login ``login``, ``plain`` SMTP Authentication Method. (Available since 4.7.0) **SMTPUser** SMTP Username. **SMTPPass** SMTP Password. **SMTPPort** 25 SMTP Port. (If set to ``465``, TLS will be used for the connection From e15078c6622d03b1ed27eced62f5c8f9d9f80374 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 22 Apr 2025 08:46:48 +0200 Subject: [PATCH 03/84] feat: rewrite `ImageMagickHandler` to rely solely on the PHP `imagick` extension (#9526) * feat: rewrite ImageMagickHandler to rely solely on the PHP imagick extension * apply the code suggestions from the review Co-authored-by: John Paul E. Balandan, CPA --------- Co-authored-by: John Paul E. Balandan, CPA --- app/Config/Images.php | 2 + system/Images/Exceptions/ImageException.php | 2 + system/Images/Handlers/ImageMagickHandler.php | 517 ++++++++++-------- system/Language/en/Images.php | 4 +- .../system/Images/ImageMagickHandlerTest.php | 62 +-- user_guide_src/source/changelogs/v4.7.0.rst | 9 +- user_guide_src/source/libraries/images.rst | 6 - utils/phpstan-baseline/argument.type.neon | 7 +- utils/phpstan-baseline/empty.notAllowed.neon | 7 +- utils/phpstan-baseline/loader.neon | 2 +- .../method.childReturnType.neon | 7 +- .../missingType.iterableValue.neon | 7 +- .../nullCoalesce.property.neon | 12 +- .../phpstan-baseline/property.phpDocType.neon | 2 +- 14 files changed, 325 insertions(+), 321 deletions(-) diff --git a/app/Config/Images.php b/app/Config/Images.php index a33ddadb9a5c..68e415e049e0 100644 --- a/app/Config/Images.php +++ b/app/Config/Images.php @@ -16,6 +16,8 @@ class Images extends BaseConfig /** * The path to the image library. * Required for ImageMagick, GraphicsMagick, or NetPBM. + * + * @deprecated 4.7.0 No longer used. */ public string $libraryPath = '/usr/local/bin/convert'; diff --git a/system/Images/Exceptions/ImageException.php b/system/Images/Exceptions/ImageException.php index 91bf416d20ac..46651fbf706d 100644 --- a/system/Images/Exceptions/ImageException.php +++ b/system/Images/Exceptions/ImageException.php @@ -100,6 +100,8 @@ public static function forSaveFailed() /** * Thrown when the image library path is invalid. * + * @deprecated 4.7.0 No longer used. + * * @return static */ public static function forInvalidImageLibraryPath(?string $path = null) diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index f1448efa7f8a..08886937a4b1 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -13,27 +13,30 @@ namespace CodeIgniter\Images\Handlers; -use CodeIgniter\I18n\Time; use CodeIgniter\Images\Exceptions\ImageException; use Config\Images; -use Exception; use Imagick; +use ImagickDraw; +use ImagickDrawException; +use ImagickException; +use ImagickPixel; +use ImagickPixelException; /** - * Class ImageMagickHandler - * - * FIXME - This needs conversion & unit testing, to use the imagick extension + * Image handler for Imagick extension. */ class ImageMagickHandler extends BaseHandler { /** - * Stores image resource in memory. + * Stores Imagick instance. * - * @var string|null + * @var Imagick|null */ protected $resource; /** + * Constructor. + * * @param Images $config * * @throws ImageException @@ -42,25 +45,95 @@ public function __construct($config = null) { parent::__construct($config); - if (! extension_loaded('imagick') && ! class_exists(Imagick::class)) { - throw ImageException::forMissingExtension('IMAGICK'); // @codeCoverageIgnore + if (! extension_loaded('imagick')) { + throw ImageException::forMissingExtension('IMAGICK'); // @codeCoverageIgnore } + } - $cmd = $this->config->libraryPath; + /** + * Loads the image for manipulation. + * + * @return void + * + * @throws ImageException + */ + protected function ensureResource() + { + if (! $this->resource instanceof Imagick) { + // Verify that we have a valid image + $this->image(); - if ($cmd === '') { - throw ImageException::forInvalidImageLibraryPath($cmd); - } + try { + $this->resource = new Imagick(); + $this->resource->readImage($this->image()->getPathname()); - if (preg_match('/convert$/i', $cmd) !== 1) { - $cmd = rtrim($cmd, '\/') . '/convert'; + // Check for valid image + if ($this->resource->getImageWidth() === 0 || $this->resource->getImageHeight() === 0) { + throw ImageException::forInvalidImageCreate($this->image()->getPathname()); + } - $this->config->libraryPath = $cmd; + $this->supportedFormatCheck(); + } catch (ImagickException $e) { + throw ImageException::forInvalidImageCreate($e->getMessage()); + } } + } + + /** + * Handles all the grunt work of resizing, etc. + * + * @param string $action Type of action to perform + * @param int $quality Quality setting for Imagick operations + * + * @return $this + * + * @throws ImageException + */ + protected function process(string $action, int $quality = 100) + { + $this->image(); + + $this->ensureResource(); + + try { + switch ($action) { + case 'resize': + $this->resource->resizeImage( + $this->width, + $this->height, + Imagick::FILTER_LANCZOS, + 0, + ); + break; - if (! is_file($cmd)) { - throw ImageException::forInvalidImageLibraryPath($cmd); + case 'crop': + $width = $this->width; + $height = $this->height; + $xAxis = $this->xAxis ?? 0; + $yAxis = $this->yAxis ?? 0; + + $this->resource->cropImage( + $width, + $height, + $xAxis, + $yAxis, + ); + + // Reset canvas to cropped size + $this->resource->setImagePage(0, 0, 0, 0); + break; + } + + // Handle transparency for supported image types + if (in_array($this->image()->imageType, $this->supportTransparency, true) + && $this->resource->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED) { + $this->resource->setImageAlphaChannel(Imagick::ALPHACHANNEL_OPAQUE); + } + } catch (ImagickException) { + throw ImageException::forImageProcessFailed(); } + + return $this; } /** @@ -68,50 +141,56 @@ public function __construct($config = null) * * @return ImageMagickHandler * - * @throws Exception + * @throws ImagickException */ public function _resize(bool $maintainRatio = false) { - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + if ($maintainRatio) { + // If maintaining a ratio, we need a custom approach + $this->ensureResource(); - $escape = '\\'; + // Use thumbnailImage which preserves an aspect ratio + $this->resource->thumbnailImage($this->width, $this->height, true); - if (PHP_OS_FAMILY === 'Windows') { - $escape = ''; + return $this; } - $action = $maintainRatio - ? ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' "' . $source . '" "' . $destination . '"' - : ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . "{$escape}! \"" . $source . '" "' . $destination . '"'; - - $this->process($action); - - return $this; + // Use the common process() method for normal resizing + return $this->process('resize'); } /** * Crops the image. * - * @return bool|ImageMagickHandler + * @return $this * - * @throws Exception + * @throws ImagickException */ public function _crop() { - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + // Use the common process() method for cropping + $result = $this->process('crop'); - $extent = ' '; - if ($this->xAxis >= $this->width || $this->yAxis > $this->height) { - $extent = ' -background transparent -extent ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' '; - } + // Handle a case where crop dimensions exceed the original image size + if ($this->resource instanceof Imagick) { + $imgWidth = $this->resource->getImageWidth(); + $imgHeight = $this->resource->getImageHeight(); - $action = ' -crop ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . '+' . ($this->xAxis ?? 0) . '+' . ($this->yAxis ?? 0) . $extent . escapeshellarg($source) . ' ' . escapeshellarg($destination); + if ($this->xAxis >= $imgWidth || $this->yAxis >= $imgHeight) { + // Create transparent background + $background = new Imagick(); + $background->newImage($this->width, $this->height, new ImagickPixel('transparent')); + $background->setImageFormat($this->resource->getImageFormat()); - $this->process($action); + // Composite our image on the background + $background->compositeImage($this->resource, Imagick::COMPOSITE_OVER, 0, 0); - return $this; + // Replace our resource + $this->resource = $background; + } + } + + return $result; } /** @@ -120,18 +199,18 @@ public function _crop() * * @return $this * - * @throws Exception + * @throws ImagickException */ protected function _rotate(int $angle) { - $angle = '-rotate ' . $angle; - - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + $this->ensureResource(); - $action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); + // Create transparent background + $this->resource->setImageBackgroundColor(new ImagickPixel('transparent')); + $this->resource->rotateImage(new ImagickPixel('transparent'), $angle); - $this->process($action); + // Reset canvas dimensions + $this->resource->setImagePage($this->resource->getImageWidth(), $this->resource->getImageHeight(), 0, 0); return $this; } @@ -141,88 +220,100 @@ protected function _rotate(int $angle) * * @return $this * - * @throws Exception + * @throws ImagickException|ImagickPixelException */ protected function _flatten(int $red = 255, int $green = 255, int $blue = 255) { - $flatten = "-background 'rgb({$red},{$green},{$blue})' -flatten"; - - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); - - $action = ' ' . $flatten . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); + $this->ensureResource(); - $this->process($action); + // Create background + $bg = new ImagickPixel("rgb({$red},{$green},{$blue})"); + + // Create a new canvas with the background color + $canvas = new Imagick(); + $canvas->newImage( + $this->resource->getImageWidth(), + $this->resource->getImageHeight(), + $bg, + $this->resource->getImageFormat(), + ); + + // Composite our image on the background + $canvas->compositeImage( + $this->resource, + Imagick::COMPOSITE_OVER, + 0, + 0, + ); + + // Replace our resource with the flattened version + $this->resource->clear(); + $this->resource = $canvas; return $this; } /** - * Flips an image along it's vertical or horizontal axis. + * Flips an image along its vertical or horizontal axis. * * @return $this * - * @throws Exception + * @throws ImagickException */ protected function _flip(string $direction) { - $angle = $direction === 'horizontal' ? '-flop' : '-flip'; - - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); - - $action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); + $this->ensureResource(); - $this->process($action); + if ($direction === 'horizontal') { + $this->resource->flopImage(); + } else { + $this->resource->flipImage(); + } return $this; } /** - * Get driver version + * Get a driver version + * + * @return string */ - public function getVersion(): string + public function getVersion() { - $versionString = $this->process('-version')[0]; - preg_match('/ImageMagick\s(?P[\S]+)/', $versionString, $matches); + $version = Imagick::getVersion(); + + if (preg_match('/ImageMagick\s+(\d+\.\d+\.\d+)/', $version['versionString'], $matches)) { + return $matches[1]; + } - return $matches['version']; + return ''; } /** - * Handles all of the grunt work of resizing, etc. + * Check if a given image format is supported * - * @return array Lines of output from shell command + * @return void * - * @throws Exception + * @throws ImageException */ - protected function process(string $action, int $quality = 100): array + protected function supportedFormatCheck() { - if ($action !== '-version') { - $this->supportedFormatCheck(); - } - - $cmd = $this->config->libraryPath; - $cmd .= $action === '-version' ? ' ' . $action : ' -quality ' . $quality . ' ' . $action; - - $retval = 1; - $output = []; - // exec() might be disabled - if (function_usable('exec')) { - @exec($cmd, $output, $retval); + if (! $this->resource instanceof Imagick) { + return; } - // Did it work? - if ($retval > 0) { - throw ImageException::forImageProcessFailed(); + switch ($this->image()->imageType) { + case IMAGETYPE_WEBP: + if (! in_array('WEBP', Imagick::queryFormats(), true)) { + throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); + } + break; } - - return $output; } /** - * Saves any changes that have been made to file. If no new filename is - * provided, the existing image is overwritten, otherwise a copy of the + * Saves any changes that have been made to the file. If no new filename is + * provided, the existing image is overwritten; otherwise a copy of the * file is made at $target. * * Example: @@ -230,6 +321,8 @@ protected function process(string $action, int $quality = 100): array * ->save(); * * @param non-empty-string|null $target + * + * @throws ImagickException */ public function save(?string $target = null, int $quality = 90): bool { @@ -238,7 +331,7 @@ public function save(?string $target = null, int $quality = 90): bool // If no new resource has been created, then we're // simply copy the existing one. - if (empty($this->resource) && $quality === 100) { + if (! $this->resource instanceof Imagick && $quality === 100) { if ($original === null) { return true; } @@ -251,192 +344,172 @@ public function save(?string $target = null, int $quality = 90): bool $this->ensureResource(); - // Copy the file through ImageMagick so that it has - // a chance to convert file format. - $action = escapeshellarg($this->resource) . ' ' . escapeshellarg($target); - - $this->process($action, $quality); - - unlink($this->resource); - - return true; - } + $this->resource->setImageCompressionQuality($quality); - /** - * Get Image Resource - * - * This simply creates an image resource handle - * based on the type of image being processed. - * Since ImageMagick is used on the cli, we need to - * ensure we have a temporary file on the server - * that we can use. - * - * To ensure we can use all features, like transparency, - * during the process, we'll use a PNG as the temp file type. - * - * @return string - * - * @throws Exception - */ - protected function getResourcePath() - { - if ($this->resource !== null) { - return $this->resource; + if ($target !== null) { + $extension = pathinfo($target, PATHINFO_EXTENSION); + $this->resource->setImageFormat($extension); } - $this->resource = WRITEPATH . 'cache/' . Time::now()->getTimestamp() . '_' . bin2hex(random_bytes(10)) . '.png'; + try { + $result = $this->resource->writeImage($target); - $name = basename($this->resource); - $path = pathinfo($this->resource, PATHINFO_DIRNAME); + chmod($target, $this->filePermissions); - $this->image()->copy($path, $name); + $this->resource->clear(); + $this->resource = null; - return $this->resource; - } - - /** - * Make the image resource object if needed - * - * @return void - * - * @throws Exception - */ - protected function ensureResource() - { - $this->getResourcePath(); - - $this->supportedFormatCheck(); - } - - /** - * Check if given image format is supported - * - * @return void - * - * @throws ImageException - */ - protected function supportedFormatCheck() - { - switch ($this->image()->imageType) { - case IMAGETYPE_WEBP: - if (! in_array('WEBP', Imagick::queryFormats(), true)) { - throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); - } - break; + return $result; + } catch (ImagickException) { + throw ImageException::forSaveFailed(); } } /** * Handler-specific method for overlaying text on an image. * - * @throws Exception + * @throws ImagickDrawException|ImagickException|ImagickPixelException */ protected function _text(string $text, array $options = []) { - $xAxis = 0; - $yAxis = 0; - $gravity = ''; - $cmd = ''; - - // Reverse the vertical offset - // When the image is positioned at the bottom - // we don't want the vertical offset to push it - // further down. We want the reverse, so we'll - // invert the offset. Note: The horizontal - // offset flips itself automatically - if ($options['vAlign'] === 'bottom') { - $options['vOffset'] *= -1; + $this->ensureResource(); + + $draw = new ImagickDraw(); + + if (isset($options['fontPath'])) { + $draw->setFont($options['fontPath']); } - if ($options['hAlign'] === 'right') { - $options['hOffset'] *= -1; + if (isset($options['fontSize'])) { + $draw->setFontSize($options['fontSize']); } - // Font - if (! empty($options['fontPath'])) { - $cmd .= " -font '{$options['fontPath']}'"; + if (isset($options['color'])) { + $color = $options['color']; + + // Shorthand hex, #f00 + if (strlen($color) === 3) { + $color = implode('', array_map(str_repeat(...), str_split($color), [2, 2, 2])); + } + + [$r, $g, $b] = sscanf("#{$color}", '#%02x%02x%02x'); + $opacity = $options['opacity'] ?? 1.0; + $draw->setFillColor(new ImagickPixel("rgba({$r},{$g},{$b},{$opacity})")); } - if (isset($options['hAlign'], $options['vAlign'])) { + // Calculate text positioning + $imgWidth = $this->resource->getImageWidth(); + $imgHeight = $this->resource->getImageHeight(); + $xAxis = 0; + $yAxis = 0; + + // Default padding + $padding = $options['padding'] ?? 0; + + if (isset($options['hAlign'])) { + $hOffset = $options['hOffset'] ?? 0; + switch ($options['hAlign']) { case 'left': - $xAxis = $options['hOffset'] + $options['padding']; - $yAxis = $options['vOffset'] + $options['padding']; - $gravity = $options['vAlign'] === 'top' ? 'NorthWest' : 'West'; - if ($options['vAlign'] === 'bottom') { - $gravity = 'SouthWest'; - $yAxis = $options['vOffset'] - $options['padding']; - } + $xAxis = $hOffset + $padding; + $draw->setTextAlignment(Imagick::ALIGN_LEFT); break; case 'center': - $xAxis = $options['hOffset'] + $options['padding']; - $yAxis = $options['vOffset'] + $options['padding']; - $gravity = $options['vAlign'] === 'top' ? 'North' : 'Center'; - if ($options['vAlign'] === 'bottom') { - $yAxis = $options['vOffset'] - $options['padding']; - $gravity = 'South'; - } + $xAxis = $imgWidth / 2 + $hOffset; + $draw->setTextAlignment(Imagick::ALIGN_CENTER); break; case 'right': - $xAxis = $options['hOffset'] - $options['padding']; - $yAxis = $options['vOffset'] + $options['padding']; - $gravity = $options['vAlign'] === 'top' ? 'NorthEast' : 'East'; - if ($options['vAlign'] === 'bottom') { - $gravity = 'SouthEast'; - $yAxis = $options['vOffset'] - $options['padding']; - } + $xAxis = $imgWidth - $hOffset - $padding; + $draw->setTextAlignment(Imagick::ALIGN_RIGHT); break; } + } - $xAxis = $xAxis >= 0 ? '+' . $xAxis : $xAxis; - $yAxis = $yAxis >= 0 ? '+' . $yAxis : $yAxis; + if (isset($options['vAlign'])) { + $vOffset = $options['vOffset'] ?? 0; - $cmd .= " -gravity {$gravity} -geometry {$xAxis}{$yAxis}"; - } + switch ($options['vAlign']) { + case 'top': + $yAxis = $vOffset + $padding + ($options['fontSize'] ?? 16); + break; - // Color - if (isset($options['color'])) { - [$r, $g, $b] = sscanf("#{$options['color']}", '#%02x%02x%02x'); + case 'middle': + $yAxis = $imgHeight / 2 + $vOffset; + break; - $cmd .= " -fill 'rgba({$r},{$g},{$b},{$options['opacity']})'"; + case 'bottom': + // Note: Vertical offset is inverted for bottom alignment as per original implementation + $yAxis = $vOffset < 0 ? $imgHeight + $vOffset - $padding : $imgHeight - $vOffset - $padding; + break; + } } - // Font Size - use points.... - if (isset($options['fontSize'])) { - $cmd .= " -pointsize {$options['fontSize']}"; - } + if (isset($options['withShadow'])) { + $shadow = clone $draw; + + if (isset($options['shadowColor'])) { + $shadowColor = $options['shadowColor']; - // Text - $cmd .= " -annotate 0 '{$text}'"; + // Shorthand hex, #f00 + if (strlen($shadowColor) === 3) { + $shadowColor = implode('', array_map(str_repeat(...), str_split($shadowColor), [2, 2, 2])); + } - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + [$sr, $sg, $sb] = sscanf("#{$shadowColor}", '#%02x%02x%02x'); + $shadow->setFillColor(new ImagickPixel("rgb({$sr},{$sg},{$sb})")); + } else { + $shadow->setFillColor(new ImagickPixel('rgba(0,0,0,0.5)')); + } - $cmd = " '{$source}' {$cmd} '{$destination}'"; + $offset = $options['shadowOffset'] ?? 3; - $this->process($cmd); + $this->resource->annotateImage( + $shadow, + $xAxis + $offset, + $yAxis + $offset, + 0, + $text, + ); + } + + // Draw the main text + $this->resource->annotateImage( + $draw, + $xAxis, + $yAxis, + 0, + $text, + ); } /** * Return the width of an image. * * @return int + * + * @throws ImagickException */ public function _getWidth() { - return imagesx(imagecreatefromstring(file_get_contents($this->resource))); + $this->ensureResource(); + + return $this->resource->getImageWidth(); } /** * Return the height of an image. * * @return int + * + * @throws ImagickException */ public function _getHeight() { - return imagesy(imagecreatefromstring(file_get_contents($this->resource))); + $this->ensureResource(); + + return $this->resource->getImageHeight(); } /** diff --git a/system/Language/en/Images.php b/system/Language/en/Images.php index 41dbe973aaa5..cdfe9ccbb181 100644 --- a/system/Language/en/Images.php +++ b/system/Language/en/Images.php @@ -24,7 +24,6 @@ 'unsupportedImageCreate' => 'Your server does not support the GD function required to process this type of image.', 'jpgOrPngRequired' => 'The image resize protocol specified in your preferences only works with JPEG or PNG image types.', 'rotateUnsupported' => 'Image rotation does not appear to be supported by your server.', - 'libPathInvalid' => 'The path to your image library is not correct. Please set the correct path in your image preferences. "{0}"', 'imageProcessFailed' => 'Image processing failed. Please verify that your server supports the chosen protocol and that the path to your image library is correct.', 'rotationAngleRequired' => 'An angle of rotation is required to rotate the image.', 'invalidPath' => 'The path to the image is not correct.', @@ -33,4 +32,7 @@ 'saveFailed' => 'Unable to save the image. Please make sure the image and file directory are writable.', 'invalidDirection' => 'Flip direction can be only "vertical" or "horizontal". Given: "{0}"', 'exifNotSupported' => 'Reading EXIF data is not supported by this PHP installation.', + + // @deprecated + 'libPathInvalid' => 'The path to your image library is not correct. Please set the correct path in your image preferences. "{0}"', ]; diff --git a/tests/system/Images/ImageMagickHandlerTest.php b/tests/system/Images/ImageMagickHandlerTest.php index 14cb4bc57980..924095253e48 100644 --- a/tests/system/Images/ImageMagickHandlerTest.php +++ b/tests/system/Images/ImageMagickHandlerTest.php @@ -16,11 +16,9 @@ use CodeIgniter\Config\Services; use CodeIgniter\Images\Exceptions\ImageException; use CodeIgniter\Images\Handlers\BaseHandler; -use CodeIgniter\Images\Handlers\ImageMagickHandler; use CodeIgniter\Test\CIUnitTestCase; use Config\Images; use Imagick; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -46,6 +44,10 @@ final class ImageMagickHandlerTest extends CIUnitTestCase protected function setUp(): void { + if (! extension_loaded('imagick')) { + $this->markTestSkipped('The IMAGICK extension is not available.'); + } + $this->root = WRITEPATH . 'cache/'; // cleanup everything @@ -58,54 +60,10 @@ protected function setUp(): void $this->path = $this->origin . 'ci-logo.png'; // get our locally available `convert` - $config = new Images(); - $found = false; - - foreach ([ - '/usr/bin/convert', - trim((string) shell_exec('which convert')), - $config->libraryPath, - ] as $convert) { - if (is_file($convert)) { - $config->libraryPath = $convert; - - $found = true; - break; - } - } - - if (! $found) { - $this->markTestSkipped('Cannot test imagick as there is no available convert program.'); - } - + $config = new Images(); $this->handler = Services::image('imagick', $config, false); } - #[DataProvider('provideNonexistentLibraryPathTerminatesProcessing')] - public function testNonexistentLibraryPathTerminatesProcessing(string $path, string $invalidPath): void - { - $this->expectException(ImageException::class); - $this->expectExceptionMessage(lang('Images.libPathInvalid', [$invalidPath])); - - $config = new Images(); - - $config->libraryPath = $path; - - new ImageMagickHandler($config); - } - - /** - * @return iterable> - */ - public static function provideNonexistentLibraryPathTerminatesProcessing(): iterable - { - yield 'empty string' => ['', '']; - - yield 'invalid file' => ['/var/log/convert', '/var/log/convert']; - - yield 'nonexistent file' => ['/var/www/file', '/var/www/file/convert']; - } - public function testGetVersion(): void { $version = $this->handler->getVersion(); @@ -458,13 +416,12 @@ public function testImageReorientLandscape(): void $this->handler->withFile($source); $this->handler->reorient(); + $this->handler->save($result); - $resource = imagecreatefromstring(file_get_contents($this->handler->getResource())); + $resource = imagecreatefromjpeg($result); $point = imagecolorat($resource, 0, 0); $rgb = imagecolorsforindex($resource, $point); - $this->handler->save($result); - $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } @@ -477,13 +434,12 @@ public function testImageReorientPortrait(): void $this->handler->withFile($source); $this->handler->reorient(); + $this->handler->save($result); - $resource = imagecreatefromstring(file_get_contents($this->handler->getResource())); + $resource = imagecreatefromjpeg($result); $point = imagecolorat($resource, 0, 0); $rgb = imagecolorsforindex($resource, $point); - $this->handler->save($result); - $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index ae6797af7884..eb12d62068cb 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -57,7 +57,8 @@ Model Libraries ========= -**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option +**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. +**Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. Helpers and Functions ===================== @@ -70,7 +71,7 @@ Message Changes *************** - Added ``Email.invalidSMTPAuthMethod`` and ``Email.failureSMTPAuthMethod`` -- Deprecated ``Email.failedSMTPLogin`` +- Deprecated ``Email.failedSMTPLogin`` and ``Image.libPathInvalid`` ******* Changes @@ -80,6 +81,10 @@ Changes Deprecations ************ +- **Image:** + - The config property ``Config\Image::libraryPath`` has been deprecated. No longer used. + - The exception method ``CodeIgniter\Images\Exceptions\ImageException::forInvalidImageLibraryPath`` has been deprecated. No longer used. + ********** Bugs Fixed ********** diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index a439c3321a0b..f263eb173d06 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -36,9 +36,6 @@ The available Handlers are as follows: - ``gd`` The GD/GD2 image library - ``imagick`` The ImageMagick library. -If using the ImageMagick library, you must set the path to the library on your -server in **app/Config/Images.php**. - .. note:: The ImageMagick handler requires the imagick extension. ******************* @@ -263,6 +260,3 @@ The possible options that are recognized are as follows: - ``vOffset`` Additional offset on the y axis, in pixels - ``fontPath`` The full server path to the TTF font you wish to use. System font will be used if none is given. - ``fontSize`` The font size to use. When using the GD handler with the system font, valid values are between ``1`` to ``5``. - -.. note:: The ImageMagick driver does not recognize full server path for fontPath. Instead, simply provide the - name of one of the installed system fonts that you wish to use, i.e., Calibri. diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index 6e6aa7de51fc..96d04deeddc6 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,4 +1,4 @@ -# total 146 errors +# total 144 errors parameters: ignoreErrors: @@ -212,11 +212,6 @@ parameters: count: 2 path: ../../tests/system/Images/GDHandlerTest.php - - - message: '#^Parameter \#1 \$filename of function file_get_contents expects string, resource given\.$#' - count: 2 - path: ../../tests/system/Images/ImageMagickHandlerTest.php - - message: '#^Parameter \#2 \$message of method CodeIgniter\\Log\\Handlers\\ChromeLoggerHandler\:\:handle\(\) expects string, stdClass given\.$#' count: 1 diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index ea06afa8b720..f9d874b1855f 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 251 errors +# total 243 errors parameters: ignoreErrors: @@ -282,11 +282,6 @@ parameters: count: 2 path: ../../system/Images/Handlers/BaseHandler.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 8 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' count: 1 diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 316b3203b5a8..1f7ef749cff2 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 3334 errors +# total 3314 errors includes: - argument.type.neon - assign.propertyType.neon diff --git a/utils/phpstan-baseline/method.childReturnType.neon b/utils/phpstan-baseline/method.childReturnType.neon index f66d38d4d6e4..e1e241985912 100644 --- a/utils/phpstan-baseline/method.childReturnType.neon +++ b/utils/phpstan-baseline/method.childReturnType.neon @@ -1,4 +1,4 @@ -# total 37 errors +# total 36 errors parameters: ignoreErrors: @@ -142,11 +142,6 @@ parameters: count: 1 path: ../../system/Images/Handlers/ImageMagickHandler.php - - - message: '#^Return type \(bool\|CodeIgniter\\Images\\Handlers\\ImageMagickHandler\) of method CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:_crop\(\) should be covariant with return type \(\$this\(CodeIgniter\\Images\\Handlers\\BaseHandler\)\) of method CodeIgniter\\Images\\Handlers\\BaseHandler\:\:_crop\(\)$#' - count: 1 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Return type \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Model\:\:__call\(\) should be covariant with return type \(\$this\(CodeIgniter\\BaseModel\)\|null\) of method CodeIgniter\\BaseModel\:\:__call\(\)$#' count: 1 diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 5dfa442af4ab..9d13bf8e6299 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1582 errors +# total 1581 errors parameters: ignoreErrors: @@ -4532,11 +4532,6 @@ parameters: count: 1 path: ../../system/Images/Handlers/BaseHandler.php - - - message: '#^Method CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:process\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Method CodeIgniter\\Images\\Image\:\:getProperties\(\) return type has no value type specified in iterable type array\.$#' count: 1 diff --git a/utils/phpstan-baseline/nullCoalesce.property.neon b/utils/phpstan-baseline/nullCoalesce.property.neon index 6caf5f23ef1d..05d64f32e0a0 100644 --- a/utils/phpstan-baseline/nullCoalesce.property.neon +++ b/utils/phpstan-baseline/nullCoalesce.property.neon @@ -1,4 +1,4 @@ -# total 20 errors +# total 12 errors parameters: ignoreErrors: @@ -27,16 +27,6 @@ parameters: count: 1 path: ../../system/HTTP/URI.php - - - message: '#^Property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$height \(int\) on left side of \?\? is not nullable\.$#' - count: 4 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - - - message: '#^Property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$width \(int\) on left side of \?\? is not nullable\.$#' - count: 4 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Property CodeIgniter\\Throttle\\Throttler\:\:\$testTime \(int\) on left side of \?\? is not nullable\.$#' count: 1 diff --git a/utils/phpstan-baseline/property.phpDocType.neon b/utils/phpstan-baseline/property.phpDocType.neon index dda215c49436..6e00157b9feb 100644 --- a/utils/phpstan-baseline/property.phpDocType.neon +++ b/utils/phpstan-baseline/property.phpDocType.neon @@ -163,7 +163,7 @@ parameters: path: ../../system/HTTP/IncomingRequest.php - - message: '#^PHPDoc type string\|null of property CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:\$resource is not the same as PHPDoc type resource\|null of overridden property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$resource\.$#' + message: '#^PHPDoc type Imagick\|null of property CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:\$resource is not the same as PHPDoc type resource\|null of overridden property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$resource\.$#' count: 1 path: ../../system/Images/Handlers/ImageMagickHandler.php From 9d12f04fbc6f747069ed658d1f14b37f3630f010 Mon Sep 17 00:00:00 2001 From: christianberkman <39840601+christianberkman@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:12:18 +0300 Subject: [PATCH 04/84] feat: add `Time::addCalendarMonths()` and `Time::subCalendarMonths()` methods (#9528) * feat: [I18n\Time] addCalendarMonths() * Remove return type * fix addCalendarMonths() return type * make addCalendarMonths() bi-directional and add subCalendarMonths() * update userguide and changelog * add tests for addCalendarMonths() and subCalendarMonths() * user guide: functions -> methods Co-authored-by: John Paul E. Balandan, CPA * revert change for TimeTrait::setTimeNow() * Update TimeTrait.php revert change to TimeTrait::setTimeNow() * update user guide * Update user_guide_src/source/libraries/time.rst Co-authored-by: Michal Sniatala --------- Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: Michal Sniatala --- system/I18n/TimeTrait.php | 33 ++++++++++++++++++++ tests/system/I18n/TimeTest.php | 32 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 5 +++ user_guide_src/source/libraries/time.rst | 18 +++++++++++ user_guide_src/source/libraries/time/031.php | 2 ++ 5 files changed, 90 insertions(+) diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 42278c960c43..6d3e7d6bb98d 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -749,6 +749,39 @@ public function addMonths(int $months) return $time->add(DateInterval::createFromDateString("{$months} months")); } + /** + * Returns a new Time instance with $months calendar months added to the time. + */ + public function addCalendarMonths(int $months): static + { + $time = clone $this; + + $year = (int) $time->getYear(); + $month = (int) $time->getMonth(); + $day = (int) $time->getDay(); + + // Adjust total months since year 0 + $totalMonths = ($year * 12 + $month - 1) + $months; + + // Recalculate year and month + $newYear = intdiv($totalMonths, 12); + $newMonth = $totalMonths % 12 + 1; + + // Get last day of new month + $lastDayOfMonth = cal_days_in_month(CAL_GREGORIAN, $newMonth, $newYear); + $correctedDay = min($day, $lastDayOfMonth); + + return static::create($newYear, $newMonth, $correctedDay, (int) $this->getHour(), (int) $this->getMinute(), (int) $this->getSecond(), $this->getTimezone(), $this->locale); + } + + /** + * Returns a new Time instance with $months calendar months subtracted from the time + */ + public function subCalendarMonths(int $months): static + { + return $this->addCalendarMonths(-$months); + } + /** * Returns a new Time instance with $years added to the time. * diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index af45abcbe4b8..4f76636ac7eb 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -812,6 +812,22 @@ public function testCanAddMonthsOverYearBoundary(): void $this->assertSame('2018-02-10 13:20:33', $newTime->toDateTimeString()); } + public function testCanAddCalendarMonths(): void + { + $time = Time::parse('January 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->addCalendarMonths(1); + $this->assertSame('2017-01-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2017-02-28 13:20:33', $newTime->toDateTimeString()); + } + + public function testCanAddCalendarMonthsOverYearBoundary(): void + { + $time = Time::parse('January 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->addCalendarMonths(13); + $this->assertSame('2017-01-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2018-02-28 13:20:33', $newTime->toDateTimeString()); + } + public function testCanAddYears(): void { $time = Time::parse('January 10, 2017 13:20:33', 'America/Chicago'); @@ -860,6 +876,22 @@ public function testCanSubtractMonths(): void $this->assertSame('2016-10-10 13:20:33', $newTime->toDateTimeString()); } + public function testCanSubtractCalendarMonths(): void + { + $time = Time::parse('March 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->subCalendarMonths(1); + $this->assertSame('2017-03-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2017-02-28 13:20:33', $newTime->toDateTimeString()); + } + + public function testCanSubtractCalendarMonthsOverYearBoundary(): void + { + $time = Time::parse('March 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->subCalendarMonths(13); + $this->assertSame('2017-03-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2016-02-29 13:20:33', $newTime->toDateTimeString()); + } + public function testCanSubtractYears(): void { $time = Time::parse('January 10, 2017 13:20:33', 'America/Chicago'); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index eb12d62068cb..13017b93db93 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -33,6 +33,11 @@ Method Signature Changes Enhancements ************ +Libraries +========= + +- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` + Commands ======== diff --git a/user_guide_src/source/libraries/time.rst b/user_guide_src/source/libraries/time.rst index 17af069c8c5f..c3ddb3c16d39 100644 --- a/user_guide_src/source/libraries/time.rst +++ b/user_guide_src/source/libraries/time.rst @@ -325,6 +325,24 @@ modify the existing Time instance, but will return a new instance. .. literalinclude:: time/031.php +addCalendarMonths() / subCalendarMonths() +----------------------------------------- + +Modifies the current Time by adding or subtracting whole calendar months. These methods can be useful if you +require no calendar months are skipped in recurring dates. Refer to the table below for a comparison between +``addMonths()`` and ``addCalendarMonths()`` for an initial date of ``2025-01-31``. + +======= =========== =================== +$months addMonths() addCalendarMonths() +======= =========== =================== +1 2025-03-03 2025-02-28 +2 2025-03-31 2025-03-31 +3 2025-05-01 2025-04-30 +4 2025-05-31 2025-05-31 +5 2025-07-01 2025-06-30 +6 2025-07-31 2025-07-31 +======= =========== =================== + Comparing Two Times =================== diff --git a/user_guide_src/source/libraries/time/031.php b/user_guide_src/source/libraries/time/031.php index 3737ae67a3c1..914ff279871d 100644 --- a/user_guide_src/source/libraries/time/031.php +++ b/user_guide_src/source/libraries/time/031.php @@ -5,6 +5,7 @@ $time = $time->addHours(12); $time = $time->addDays(21); $time = $time->addMonths(14); +$time = $time->addCalendarMonths(2); $time = $time->addYears(5); $time = $time->subSeconds(23); @@ -12,4 +13,5 @@ $time = $time->subHours(12); $time = $time->subDays(21); $time = $time->subMonths(14); +$time = $time->subCalendarMonths(2); $time = $time->subYears(5); From ac89c61d8ba4c185c987fdf40c6e5be4619f44f9 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Thu, 8 May 2025 20:47:40 +0200 Subject: [PATCH 05/84] feat: add `clearMetadata()` method to provide privacy options when using imagick handler (#9538) * feat: add clearMetadata() method to provide privacy options when using imagick handler * simplify tests * update clearMetadata() * fix test * add clearMetadata to the interface * update method description --- system/Images/Handlers/BaseHandler.php | 10 ++++++ system/Images/Handlers/ImageMagickHandler.php | 16 +++++++++ system/Images/ImageHandlerInterface.php | 7 ++++ tests/system/Images/GDHandlerTest.php | 9 +++++ .../system/Images/ImageMagickHandlerTest.php | 36 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 3 ++ user_guide_src/source/libraries/images.rst | 15 ++++++++ .../source/libraries/images/015.php | 6 ++++ utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/varTag.type.neon | 7 +++- 10 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 user_guide_src/source/libraries/images/015.php diff --git a/system/Images/Handlers/BaseHandler.php b/system/Images/Handlers/BaseHandler.php index de3e15974a1b..e38c5836ccca 100644 --- a/system/Images/Handlers/BaseHandler.php +++ b/system/Images/Handlers/BaseHandler.php @@ -769,4 +769,14 @@ public function getHeight() { return ($this->resource !== null) ? $this->_getHeight() : $this->height; } + + /** + * Placeholder method for implementing metadata clearing logic. + * + * This method should be implemented to remove or reset metadata as needed. + */ + public function clearMetadata(): static + { + return $this; + } } diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index 08886937a4b1..2e8b56acd858 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -537,4 +537,20 @@ public function reorient(bool $silent = false) default => $this, }; } + + /** + * Clears metadata from the image. + * + * @return $this + * + * @throws ImagickException + */ + public function clearMetadata(): static + { + $this->ensureResource(); + + $this->resource->stripImage(); + + return $this; + } } diff --git a/system/Images/ImageHandlerInterface.php b/system/Images/ImageHandlerInterface.php index 93b2009d6a93..a96977214ec8 100644 --- a/system/Images/ImageHandlerInterface.php +++ b/system/Images/ImageHandlerInterface.php @@ -149,4 +149,11 @@ public function text(string $text, array $options = []); * @return bool */ public function save(?string $target = null, int $quality = 90); + + /** + * Clear metadata before saving image as a new file. + * + * @return $this + */ + public function clearMetadata(): static; } diff --git a/tests/system/Images/GDHandlerTest.php b/tests/system/Images/GDHandlerTest.php index 121ce040904a..d85d1a142ffd 100644 --- a/tests/system/Images/GDHandlerTest.php +++ b/tests/system/Images/GDHandlerTest.php @@ -454,4 +454,13 @@ public function testImageReorientPortrait(): void $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } + + public function testClearMetadataReturnsSelf(): void + { + $this->handler->withFile($this->path); + + $result = $this->handler->clearMetadata(); + + $this->assertSame($this->handler, $result); + } } diff --git a/tests/system/Images/ImageMagickHandlerTest.php b/tests/system/Images/ImageMagickHandlerTest.php index 924095253e48..be6bee2cf798 100644 --- a/tests/system/Images/ImageMagickHandlerTest.php +++ b/tests/system/Images/ImageMagickHandlerTest.php @@ -443,4 +443,40 @@ public function testImageReorientPortrait(): void $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } + + public function testClearMetadataEnsuresResource(): void + { + $this->expectException(ImageException::class); + $this->handler->clearMetadata(); + } + + public function testClearMetadataReturnsSelf(): void + { + $this->handler->withFile($this->path); + + $result = $this->handler->clearMetadata(); + + $this->assertSame($this->handler, $result); + } + + public function testClearMetadata(): void + { + $this->handler->withFile($this->origin . 'Steveston_dusk.JPG'); + /** @var Imagick $imagick */ + $imagick = $this->handler->getResource(); + $before = $imagick->getImageProperties(); + + $this->assertGreaterThan(40, count($before)); + + $this->handler + ->clearMetadata() + ->save($this->root . 'exif-info-no-metadata.jpg'); + + $this->handler->withFile($this->root . 'exif-info-no-metadata.jpg'); + /** @var Imagick $imagick */ + $imagick = $this->handler->getResource(); + $after = $imagick->getImageProperties(); + + $this->assertLessThanOrEqual(5, count($after)); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 13017b93db93..2f48a1007161 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -26,6 +26,8 @@ Behavior Changes Interface Changes ================= +- **Images:** The ``ImageHandlerInterface`` now includes a new method: ``clearMetadata()``. If you've implemented your own handler from scratch, you will need to provide an implementation for this method to ensure compatibility. + Method Signature Changes ======================== @@ -64,6 +66,7 @@ Libraries **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. +**Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. Helpers and Functions ===================== diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index f263eb173d06..b2b6b0238942 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -260,3 +260,18 @@ The possible options that are recognized are as follows: - ``vOffset`` Additional offset on the y axis, in pixels - ``fontPath`` The full server path to the TTF font you wish to use. System font will be used if none is given. - ``fontSize`` The font size to use. When using the GD handler with the system font, valid values are between ``1`` to ``5``. + +Clearing Image Metadata +======================= + +This method removes metadata (EXIF, XMP, ICC, IPTC, comments, etc.) from an image. + +.. important:: The GD image library automatically strips all metadata during processing, + so this method has no additional effect when using the GD handler. + This behavior is built into GD itself and cannot be modified. + +Some essential technical metadata (dimensions, color depth) will be regenerated during save operations +as they're required for image display. However, all privacy-sensitive information such as GPS location, +camera details, and timestamps will be completely removed. + +.. literalinclude:: images/015.php diff --git a/user_guide_src/source/libraries/images/015.php b/user_guide_src/source/libraries/images/015.php new file mode 100644 index 000000000000..446deab2d35f --- /dev/null +++ b/user_guide_src/source/libraries/images/015.php @@ -0,0 +1,6 @@ +withFile('/path/to/image/mypic.jpg') + ->clearMetadata() + ->save('/path/to/new/image.jpg'); diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index e4506414128e..f93eac55feb4 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 3253 errors +# total 3255 errors includes: - argument.type.neon - assign.propertyType.neon diff --git a/utils/phpstan-baseline/varTag.type.neon b/utils/phpstan-baseline/varTag.type.neon index 4be1089ace00..099fce1db55c 100644 --- a/utils/phpstan-baseline/varTag.type.neon +++ b/utils/phpstan-baseline/varTag.type.neon @@ -1,7 +1,12 @@ -# total 2 errors +# total 4 errors parameters: ignoreErrors: + - + message: '#^PHPDoc tag @var with type Imagick is not subtype of type resource\.$#' + count: 2 + path: ../../tests/system/Images/ImageMagickHandlerTest.php + - message: '#^PHPDoc tag @var with type Tests\\Support\\Entity\\UserWithCasts is not subtype of type list\\|null\.$#' count: 1 From f7d614285cf67719ca914b21ac53b374bcf138a5 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Fri, 16 May 2025 01:52:47 +0700 Subject: [PATCH 06/84] feat: add `dns_cache_timeout` for option `CURLRequest` (#9553) * feat: added dns_cache_timeout for option CURLRequest * tests: added option when set to not numeric * docs: added dns_cache_timeout options to CURLRequest * Update user_guide_src/source/libraries/curlrequest.rst Co-authored-by: John Paul E. Balandan, CPA * fix: more test coverage dns_cache_timeout options CURLRequest * fix: specified variable doctype * fix: more coverage * tests: added more coverage * docs: added notes libcurl * docs: remove notes PHP * Update tests/system/HTTP/CURLRequestTest.php Co-authored-by: John Paul E. Balandan, CPA * Update tests/system/HTTP/CURLRequestTest.php Co-authored-by: John Paul E. Balandan, CPA * Update user_guide_src/source/libraries/curlrequest.rst Co-authored-by: John Paul E. Balandan, CPA --------- Co-authored-by: John Paul E. Balandan, CPA --- system/HTTP/CURLRequest.php | 5 ++ tests/system/HTTP/CURLRequestTest.php | 71 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 11 ++- .../source/libraries/curlrequest.rst | 12 ++++ .../source/libraries/curlrequest/037.php | 4 ++ 5 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 user_guide_src/source/libraries/curlrequest/037.php diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 8a21d77262a0..ef5d10880ae6 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -616,6 +616,11 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) } } + // DNS Cache Timeout + if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) { + $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout']; + } + // Timeout $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000; diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index e72a1d37d17c..b44fbe4db99f 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -22,6 +22,7 @@ use Config\CURLRequest as ConfigCURLRequest; use CURLFile; use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -1212,6 +1213,76 @@ public function testForceResolveIPUnknown(): void $this->assertSame(\CURL_IPRESOLVE_WHATEVER, $options[CURLOPT_IPRESOLVE]); } + /** + * @return iterable + * + * @see https://curl.se/libcurl/c/CURLOPT_DNS_CACHE_TIMEOUT.html + */ + public static function provideDNSCacheTimeout(): iterable + { + yield from [ + 'valid timeout (integer)' => [ + 'input' => 160, + 'expectedHasKey' => true, + 'expectedValue' => 160, + ], + 'valid timeout (numeric string)' => [ + 'input' => '180', + 'expectedHasKey' => true, + 'expectedValue' => 180, + ], + 'valid timeout (zero / disabled)' => [ + 'input' => 0, + 'expectedHasKey' => true, + 'expectedValue' => 0, + ], + 'valid timeout (zero / disabled using string)' => [ + 'input' => '0', + 'expectedHasKey' => true, + 'expectedValue' => 0, + ], + 'valid timeout (forever)' => [ + 'input' => -1, + 'expectedHasKey' => true, + 'expectedValue' => -1, + ], + 'valid timeout (forever using string)' => [ + 'input' => '-1', + 'expectedHasKey' => true, + 'expectedValue' => -1, + ], + 'invalid timeout (null)' => [ + 'input' => null, + 'expectedHasKey' => false, + ], + 'invalid timeout (string)' => [ + 'input' => 'is_wrong', + 'expectedHasKey' => false, + ], + 'invalid timeout (negative number / below -1)' => [ + 'input' => -2, + 'expectedHasKey' => false, + ], + ]; + } + + #[DataProvider('provideDNSCacheTimeout')] + public function testDNSCacheTimeoutOption(int|string|null $input, bool $expectedHasKey, ?int $expectedValue = null): void + { + $this->request->request('POST', '/post', [ + 'dns_cache_timeout' => $input, + ]); + + $options = $this->request->curl_options; + + if ($expectedHasKey) { + $this->assertArrayHasKey(CURLOPT_DNS_CACHE_TIMEOUT, $options); + $this->assertSame($expectedValue, $options[CURLOPT_DNS_CACHE_TIMEOUT]); + } else { + $this->assertArrayNotHasKey(CURLOPT_DNS_CACHE_TIMEOUT, $options); + } + } + public function testCookieOption(): void { $holder = SUPPORTPATH . 'HTTP/Files/CookiesHolder.txt'; diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 2f48a1007161..a91cb468f542 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -38,6 +38,10 @@ Enhancements Libraries ========= +- **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. +- **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. +- **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. +- **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` Commands @@ -61,13 +65,6 @@ Others Model ===== -Libraries -========= - -**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. -**Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. -**Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - Helpers and Functions ===================== diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index 49540613e1cc..45de3ae5cdd6 100644 --- a/user_guide_src/source/libraries/curlrequest.rst +++ b/user_guide_src/source/libraries/curlrequest.rst @@ -251,6 +251,18 @@ Allows you to pause a number of milliseconds before sending the request: .. literalinclude:: curlrequest/023.php +dns_cache_timeout +================= + +.. versionadded:: 4.7.0 + +By default, CodeIgniter does not change the DNS Cache Timeout value (``120`` seconds). If you need to +modify this value, you can do so by passing an amount of time in seconds with the ``dns_cache_timeout`` option. + +.. literalinclude:: curlrequest/037.php + +.. note:: Based on the `libcurl `__ documentation, you can set to zero (``0``) to completely disable caching, or set to ``-1`` to make the cached entries remain forever. + form_params =========== diff --git a/user_guide_src/source/libraries/curlrequest/037.php b/user_guide_src/source/libraries/curlrequest/037.php new file mode 100644 index 000000000000..7d71d90bec70 --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/037.php @@ -0,0 +1,4 @@ +request('GET', '/', ['dns_cache_timeout' => 360]); // seconds From 59a48193462e2ee0c9372fd8209a7391149de402 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Fri, 16 May 2025 12:53:50 +0700 Subject: [PATCH 07/84] feat: added `fresh_connect` options to `CURLRequest` (#9559) * feat: added options fresh_connect to CURLRequest * docs: added options fresh_connect to CURLRequest * docs: mention default value --- system/HTTP/CURLRequest.php | 6 ++++- tests/system/HTTP/CURLRequestTest.php | 22 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + .../source/libraries/curlrequest.rst | 9 ++++++++ .../source/libraries/curlrequest/038.php | 4 ++++ 5 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 user_guide_src/source/libraries/curlrequest/038.php diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index ef5d10880ae6..f160f0932733 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -365,7 +365,6 @@ public function send(string $method, string $url) $curlOptions[CURLOPT_URL] = $url; $curlOptions[CURLOPT_RETURNTRANSFER] = true; $curlOptions[CURLOPT_HEADER] = true; - $curlOptions[CURLOPT_FRESH_CONNECT] = true; // Disable @file uploads in post data. $curlOptions[CURLOPT_SAFE_UPLOAD] = true; @@ -621,6 +620,11 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout']; } + // Fresh Connect (default true) + $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect']) + ? $config['fresh_connect'] + : true; + // Timeout $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000; diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index b44fbe4db99f..508c4f2f314f 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -583,6 +583,28 @@ public function testProxyuOption(): void $this->assertTrue($options[CURLOPT_HTTPPROXYTUNNEL]); } + public function testFreshConnectDefault(): void + { + $this->request->request('get', 'http://example.com'); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FRESH_CONNECT, $options); + $this->assertTrue($options[CURLOPT_FRESH_CONNECT]); + } + + public function testFreshConnectFalseOption(): void + { + $this->request->request('get', 'http://example.com', [ + 'fresh_connect' => false, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FRESH_CONNECT, $options); + $this->assertFalse($options[CURLOPT_FRESH_CONNECT]); + } + public function testDebugOptionTrue(): void { $this->request->request('get', 'http://example.com', [ diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index a91cb468f542..7645847c59e9 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -39,6 +39,7 @@ Libraries ========= - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. +- **CURLRequest:** Added ``fresh_connect`` options to enable/disabled request fresh connection. - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index 45de3ae5cdd6..8be3b098b287 100644 --- a/user_guide_src/source/libraries/curlrequest.rst +++ b/user_guide_src/source/libraries/curlrequest.rst @@ -278,6 +278,15 @@ if it's not already set: .. _curlrequest-request-options-headers: +fresh_connect +============= + +.. versionadded:: 4.7.0 + +By default, the request is sent using a fresh connection. You can disable this behavior using the ``fresh_connect`` option: + +.. literalinclude:: curlrequest/038.php + headers ======= diff --git a/user_guide_src/source/libraries/curlrequest/038.php b/user_guide_src/source/libraries/curlrequest/038.php new file mode 100644 index 000000000000..83e971d2782a --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/038.php @@ -0,0 +1,4 @@ +request('GET', 'http://example.com', ['fresh_connect' => true]); +$client->request('GET', 'http://example.com', ['fresh_connect' => false]); From e1317af60e2ceb5127a83fbde0cbc7da06535b1b Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Mon, 19 May 2025 17:42:51 +0700 Subject: [PATCH 08/84] refactor: cleanup code in Email (#9570) --- system/Email/Email.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/system/Email/Email.php b/system/Email/Email.php index 4d2cbaf2958a..adb8501d5881 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -2036,15 +2036,15 @@ protected function SMTPAuthenticate() $this->SMTPAuthMethod = strtolower($this->SMTPAuthMethod); // Validate supported authentication methods - $validMethods = ['login', 'plain']; - if (! in_array($this->SMTPAuthMethod, $validMethods, true)) { + if (! in_array($this->SMTPAuthMethod, ['login', 'plain'], true)) { $this->setErrorMessage(lang('Email.invalidSMTPAuthMethod', [$this->SMTPAuthMethod])); return false; } + $upperAuthMethod = strtoupper($this->SMTPAuthMethod); // send initial 'AUTH' command - $this->sendData('AUTH ' . strtoupper($this->SMTPAuthMethod)); + $this->sendData('AUTH ' . $upperAuthMethod); $reply = $this->getSMTPData(); if (str_starts_with($reply, '503')) { // Already authenticated @@ -2053,7 +2053,7 @@ protected function SMTPAuthenticate() // if 'AUTH' command is unsuported by the server if (! str_starts_with($reply, '334')) { - $this->setErrorMessage(lang('Email.failureSMTPAuthMethod', [strtoupper($this->SMTPAuthMethod)])); + $this->setErrorMessage(lang('Email.failureSMTPAuthMethod', [$upperAuthMethod])); return false; } From cf6011fcbe57c51763d522c4441d22ab0aef9ef7 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 19 May 2025 18:43:21 +0800 Subject: [PATCH 09/84] feat: update `CookieInterface::EXPIRES_FORMAT` to use date format per RFC 7231 (#9563) * Update cookie expires to `DATE_RFC7231` * Add to changelog * Fix doc example --- system/Cookie/CookieInterface.php | 2 +- tests/system/Cookie/CookieTest.php | 8 ++++---- user_guide_src/source/changelogs/v4.7.0.rst | 2 ++ user_guide_src/source/libraries/cookies/004.php | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/system/Cookie/CookieInterface.php b/system/Cookie/CookieInterface.php index c848fa8884ec..8317617088cd 100644 --- a/system/Cookie/CookieInterface.php +++ b/system/Cookie/CookieInterface.php @@ -57,7 +57,7 @@ interface CookieInterface * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date * @see https://tools.ietf.org/html/rfc7231#section-7.1.1.2 */ - public const EXPIRES_FORMAT = 'D, d-M-Y H:i:s T'; + public const EXPIRES_FORMAT = DATE_RFC7231; /** * Returns a unique identifier for the cookie consisting diff --git a/tests/system/Cookie/CookieTest.php b/tests/system/Cookie/CookieTest.php index e4421a8bc443..1438cfb9d513 100644 --- a/tests/system/Cookie/CookieTest.php +++ b/tests/system/Cookie/CookieTest.php @@ -155,14 +155,14 @@ public function testExpirationTime(): void // expires => 0 $cookie = new Cookie('test', 'value'); $this->assertSame(0, $cookie->getExpiresTimestamp()); - $this->assertSame('Thu, 01-Jan-1970 00:00:00 GMT', $cookie->getExpiresString()); + $this->assertSame('Thu, 01 Jan 1970 00:00:00 GMT', $cookie->getExpiresString()); $this->assertTrue($cookie->isExpired()); $this->assertSame(0, $cookie->getMaxAge()); $date = new DateTimeImmutable('2021-01-10 00:00:00 GMT', new DateTimeZone('UTC')); $cookie = new Cookie('test', 'value', ['expires' => $date]); $this->assertSame((int) $date->format('U'), $cookie->getExpiresTimestamp()); - $this->assertSame('Sun, 10-Jan-2021 00:00:00 GMT', $cookie->getExpiresString()); + $this->assertSame('Sun, 10 Jan 2021 00:00:00 GMT', $cookie->getExpiresString()); } /** @@ -272,7 +272,7 @@ public function testStringCastingOfCookies(): void $a->toHeaderString(), ); $this->assertSame( - "cookie=monster; Expires=Sun, 14-Feb-2021 00:00:00 GMT; Max-Age={$max}; Path=/web; Domain=localhost; HttpOnly; SameSite=Lax", + "cookie=monster; Expires=Sun, 14 Feb 2021 00:00:00 GMT; Max-Age={$max}; Path=/web; Domain=localhost; HttpOnly; SameSite=Lax", (string) $b, ); $this->assertSame( @@ -280,7 +280,7 @@ public function testStringCastingOfCookies(): void (string) $c, ); $this->assertSame( - 'cookie=deleted; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=Lax', + 'cookie=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=Lax', (string) $d, ); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 7645847c59e9..a64f4245de05 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -83,6 +83,8 @@ Message Changes Changes ******* +- **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s \G\M\T`` to follow the recommended format in RFC 7231. + ************ Deprecations ************ diff --git a/user_guide_src/source/libraries/cookies/004.php b/user_guide_src/source/libraries/cookies/004.php index e39854766db1..d68d97081713 100644 --- a/user_guide_src/source/libraries/cookies/004.php +++ b/user_guide_src/source/libraries/cookies/004.php @@ -23,7 +23,7 @@ $cookie->getPrefix(); // '__Secure-' $cookie->getPrefixedName(); // '__Secure-remember_token' $cookie->getExpiresTimestamp(); // UNIX timestamp -$cookie->getExpiresString(); // 'Fri, 14-Feb-2025 00:00:00 GMT' +$cookie->getExpiresString(); // 'Fri, 14 Feb 2025 00:00:00 GMT' $cookie->isExpired(); // false $cookie->getMaxAge(); // the difference from time() to expires $cookie->isRaw(); // false From 4d912e39c7161ab08cf92c9ebbe4b8ef192e44d8 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 21 May 2025 02:13:05 +0800 Subject: [PATCH 10/84] fix: ucfirst all cookie samesite values (#9564) * fix: ucfirst all cookie samesite values * Fix doc sample * Add changelog * Fix review * Add case-insensitive validation of SameSite --- system/Cookie/Cookie.php | 4 ++-- system/Cookie/CookieInterface.php | 6 +++--- user_guide_src/source/changelogs/v4.7.0.rst | 2 ++ user_guide_src/source/libraries/cookies/006.php | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php index df75c03c7bcb..b677bb944e31 100644 --- a/system/Cookie/Cookie.php +++ b/system/Cookie/Cookie.php @@ -766,11 +766,11 @@ protected function validateSameSite(string $samesite, bool $secure): void $samesite = self::SAMESITE_LAX; } - if (! in_array(strtolower($samesite), self::ALLOWED_SAMESITE_VALUES, true)) { + if (! in_array(ucfirst(strtolower($samesite)), self::ALLOWED_SAMESITE_VALUES, true)) { throw CookieException::forInvalidSameSite($samesite); } - if (strtolower($samesite) === self::SAMESITE_NONE && ! $secure) { + if (ucfirst(strtolower($samesite)) === self::SAMESITE_NONE && ! $secure) { throw CookieException::forInvalidSameSiteNone(); } } diff --git a/system/Cookie/CookieInterface.php b/system/Cookie/CookieInterface.php index 8317617088cd..b63cb4c07833 100644 --- a/system/Cookie/CookieInterface.php +++ b/system/Cookie/CookieInterface.php @@ -25,20 +25,20 @@ interface CookieInterface * first-party and cross-origin requests. If `SameSite=None` is set, * the cookie `Secure` attribute must also be set (or the cookie will be blocked). */ - public const SAMESITE_NONE = 'none'; + public const SAMESITE_NONE = 'None'; /** * Cookies are not sent on normal cross-site subrequests (for example to * load images or frames into a third party site), but are sent when a * user is navigating to the origin site (i.e. when following a link). */ - public const SAMESITE_LAX = 'lax'; + public const SAMESITE_LAX = 'Lax'; /** * Cookies will only be sent in a first-party context and not be sent * along with requests initiated by third party websites. */ - public const SAMESITE_STRICT = 'strict'; + public const SAMESITE_STRICT = 'Strict'; /** * RFC 6265 allowed values for the "SameSite" attribute. diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index a64f4245de05..7182a8dddb53 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -97,6 +97,8 @@ Deprecations Bugs Fixed ********** +- **Cookie:** The ``CookieInterface::SAMESITE_STRICT``, ``CookieInterface::SAMESITE_LAX``, and ``CookieInterface::SAMESITE_NONE`` constants are now written in ucfirst style to be consistent with usage in the rest of the framework. + See the repo's `CHANGELOG.md `_ for a complete list of bugs fixed. diff --git a/user_guide_src/source/libraries/cookies/006.php b/user_guide_src/source/libraries/cookies/006.php index 1bf6c732b71a..512d3e8fefe0 100644 --- a/user_guide_src/source/libraries/cookies/006.php +++ b/user_guide_src/source/libraries/cookies/006.php @@ -2,6 +2,6 @@ use CodeIgniter\Cookie\Cookie; -Cookie::SAMESITE_LAX; // 'lax' -Cookie::SAMESITE_STRICT; // 'strict' -Cookie::SAMESITE_NONE; // 'none' +Cookie::SAMESITE_LAX; // 'Lax' +Cookie::SAMESITE_STRICT; // 'Strict' +Cookie::SAMESITE_NONE; // 'None' From 4046b8ddf10afe8dd2133693b82226c984dcf6bd Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Mon, 26 May 2025 14:02:41 +0700 Subject: [PATCH 11/84] feat: share connection & DNS Cache to `CURLRequest` (#9557) * feat: share Connection & DNS Cache to CURLRequest * feat: share connection in CURLRequest * fix: compatible function parameter * docs: fix typo * docs: sample * refactor: revision suggest recommendation * refactor: revision suggest recommendation * Update user_guide_src/source/libraries/curlrequest.rst Co-authored-by: Michal Sniatala * refactor: more applied suggest recommendation --------- Co-authored-by: Michal Sniatala --- app/Config/CURLRequest.php | 16 +++++++ system/HTTP/CURLRequest.php | 27 ++++++++++- .../HTTP/CURLRequestShareOptionsTest.php | 7 ++- tests/system/HTTP/CURLRequestTest.php | 45 ++++++++++++++++++- user_guide_src/source/changelogs/v4.7.0.rst | 1 + .../source/libraries/curlrequest.rst | 17 +++++++ .../source/libraries/curlrequest/039.php | 26 +++++++++++ .../source/libraries/curlrequest/040.php | 23 ++++++++++ 8 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 user_guide_src/source/libraries/curlrequest/039.php create mode 100644 user_guide_src/source/libraries/curlrequest/040.php diff --git a/app/Config/CURLRequest.php b/app/Config/CURLRequest.php index 5a3d4e9311b2..bc5947fb7bb7 100644 --- a/app/Config/CURLRequest.php +++ b/app/Config/CURLRequest.php @@ -6,6 +6,22 @@ class CURLRequest extends BaseConfig { + /** + * -------------------------------------------------------------------------- + * CURLRequest Share Connection Options + * -------------------------------------------------------------------------- + * + * Share connection options between requests. + * + * @var list + * + * @see https://www.php.net/manual/en/curl.constants.php#constant.curl-lock-data-connect + */ + public array $shareConnectionOptions = [ + CURL_LOCK_DATA_CONNECT, + CURL_LOCK_DATA_DNS, + ]; + /** * -------------------------------------------------------------------------- * CURLRequest Share Options diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index f160f0932733..a5778bd81f8e 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -17,6 +17,7 @@ use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; use Config\CURLRequest as ConfigCURLRequest; +use CurlShareHandle; /** * A lightweight HTTP client for sending synchronous HTTP requests via cURL. @@ -101,6 +102,11 @@ class CURLRequest extends OutgoingRequest */ private readonly bool $shareOptions; + /** + * The share connection instance. + */ + protected ?CurlShareHandle $shareConnection = null; + /** * Takes an array of options to set the following possible class properties: * @@ -129,6 +135,20 @@ public function __construct(App $config, URI $uri, ?ResponseInterface $response $this->config = $this->defaultConfig; $this->parseOptions($options); + + // Share Connection + $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ + CURL_LOCK_DATA_CONNECT, + CURL_LOCK_DATA_DNS, + ]; + + if ($optShareConnection !== []) { + $this->shareConnection = curl_share_init(); + + foreach (array_unique($optShareConnection) as $opt) { + curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, $opt); + } + } } /** @@ -364,7 +384,12 @@ public function send(string $method, string $url) $curlOptions[CURLOPT_URL] = $url; $curlOptions[CURLOPT_RETURNTRANSFER] = true; - $curlOptions[CURLOPT_HEADER] = true; + + if ($this->shareConnection instanceof CurlShareHandle) { + $curlOptions[CURLOPT_SHARE] = $this->shareConnection; + } + + $curlOptions[CURLOPT_HEADER] = true; // Disable @file uploads in post data. $curlOptions[CURLOPT_SAFE_UPLOAD] = true; diff --git a/tests/system/HTTP/CURLRequestShareOptionsTest.php b/tests/system/HTTP/CURLRequestShareOptionsTest.php index 8f6277cf9501..54e8e46a0bc8 100644 --- a/tests/system/HTTP/CURLRequestShareOptionsTest.php +++ b/tests/system/HTTP/CURLRequestShareOptionsTest.php @@ -28,13 +28,18 @@ #[Group('Others')] final class CURLRequestShareOptionsTest extends CURLRequestTest { - protected function getRequest(array $options = []): MockCURLRequest + protected function getRequest(array $options = [], ?array $shareConnectionOptions = null): MockCURLRequest { $uri = isset($options['baseURI']) ? new URI($options['baseURI']) : new URI(); $app = new App(); $config = new ConfigCURLRequest(); $config->shareOptions = true; + + if ($shareConnectionOptions !== null) { + $config->shareConnectionOptions = $shareConnectionOptions; + } + Factories::injectMock('config', 'CURLRequest', $config); return new MockCURLRequest(($app), $uri, new Response($app), $options); diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index 508c4f2f314f..7f1462cbb8ef 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -21,6 +21,7 @@ use Config\App; use Config\CURLRequest as ConfigCURLRequest; use CURLFile; +use CurlShareHandle; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -45,14 +46,20 @@ protected function setUp(): void /** * @param array $options + * @param array|null $shareConnectionOptions */ - protected function getRequest(array $options = []): MockCURLRequest + protected function getRequest(array $options = [], ?array $shareConnectionOptions = null): MockCURLRequest { $uri = isset($options['baseURI']) ? new URI($options['baseURI']) : new URI(); $app = new App(); $config = new ConfigCURLRequest(); $config->shareOptions = false; + + if ($shareConnectionOptions !== null) { + $config->shareConnectionOptions = $shareConnectionOptions; + } + Factories::injectMock('config', 'CURLRequest', $config); return new MockCURLRequest(($app), $uri, new Response($app), $options); @@ -1235,6 +1242,42 @@ public function testForceResolveIPUnknown(): void $this->assertSame(\CURL_IPRESOLVE_WHATEVER, $options[CURLOPT_IPRESOLVE]); } + public function testShareConnectionDefault(): void + { + $this->request->request('GET', 'http://example.com'); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_SHARE, $options); + $this->assertInstanceOf(CurlShareHandle::class, $options[CURLOPT_SHARE]); + } + + public function testShareConnectionEmpty(): void + { + $request = $this->getRequest(shareConnectionOptions: []); + $request->request('GET', 'http://example.com'); + + $options = $request->curl_options; + + $this->assertArrayNotHasKey(CURLOPT_SHARE, $options); + } + + public function testShareConnectionDuplicate(): void + { + $request = $this->getRequest(shareConnectionOptions: [ + CURL_LOCK_DATA_CONNECT, + CURL_LOCK_DATA_DNS, + CURL_LOCK_DATA_CONNECT, + CURL_LOCK_DATA_DNS, + ]); + $request->request('GET', 'http://example.com'); + + $options = $request->curl_options; + + $this->assertArrayHasKey(CURLOPT_SHARE, $options); + $this->assertInstanceOf(CurlShareHandle::class, $options[CURLOPT_SHARE]); + } + /** * @return iterable * diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 7182a8dddb53..ac91c4851370 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -38,6 +38,7 @@ Enhancements Libraries ========= +- **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disabled request fresh connection. - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index 8be3b098b287..c1bbc4eccdfd 100644 --- a/user_guide_src/source/libraries/curlrequest.rst +++ b/user_guide_src/source/libraries/curlrequest.rst @@ -23,6 +23,23 @@ to change very little to move over to use Guzzle. Config for CURLRequest ********************** +.. _curlrequest-sharing-connection: + +Sharing Connection +================== + +.. versionadded:: 4.7.0 + +By default, this option is enabled with the constants ``CURL_LOCK_DATA_CONNECT`` and ``CURL_LOCK_DATA_DNS``. + +If you want to share connection between requests, set ``$shareConnectionOptions`` with array constant `CURL_LOCK_DATA_* `_ in **app/Config/CURLRequest.php**: + +.. literalinclude:: curlrequest/039.php + +or when you want to disable it, just change to empty array: + +.. literalinclude:: curlrequest/040.php + .. _curlrequest-sharing-options: Sharing Options diff --git a/user_guide_src/source/libraries/curlrequest/039.php b/user_guide_src/source/libraries/curlrequest/039.php new file mode 100644 index 000000000000..9dda44d9312c --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/039.php @@ -0,0 +1,26 @@ + + * + * @see https://www.php.net/manual/en/curl.constants.php#constant.curl-lock-data-connect + */ + public array $shareConnection = [ + CURL_LOCK_DATA_CONNECT, + CURL_LOCK_DATA_DNS, + ]; + + // ... +} diff --git a/user_guide_src/source/libraries/curlrequest/040.php b/user_guide_src/source/libraries/curlrequest/040.php new file mode 100644 index 000000000000..bd68f56a04b8 --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/040.php @@ -0,0 +1,23 @@ + + * + * @see https://www.php.net/manual/en/curl.constants.php#constant.curl-lock-data-connect + */ + public array $shareConnection = []; + + // ... +} From fa1178ddc1631d007ca51e655b3ef3ba1c1c7bef Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Sat, 31 May 2025 14:04:01 +0700 Subject: [PATCH 12/84] feat: add option to change default behaviour of `JSONFormatter` max depth (#9585) * feat: added options for change default behaviour json depth formatter * refactor: remove variable * docs: added options jsonDepthOptions to json formatter * refactor: apply suggestion --- app/Config/Format.php | 9 +++++++++ system/Format/JSONFormatter.php | 2 +- user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/outgoing/api_responses.rst | 2 ++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/Config/Format.php b/app/Config/Format.php index 0d334d72b3fb..5bf56c22fd14 100644 --- a/app/Config/Format.php +++ b/app/Config/Format.php @@ -61,4 +61,13 @@ class Format extends BaseConfig 'application/xml' => 0, 'text/xml' => 0, ]; + + /** + * -------------------------------------------------------------------------- + * Maximum depth for JSON encoding. + * -------------------------------------------------------------------------- + * + * This value determines how deep the JSON encoder will traverse nested structures. + */ + public int $jsonEncodeDepth = 512; } diff --git a/system/Format/JSONFormatter.php b/system/Format/JSONFormatter.php index a6ba87cd724a..1ca6feb6d580 100644 --- a/system/Format/JSONFormatter.php +++ b/system/Format/JSONFormatter.php @@ -41,7 +41,7 @@ public function format($data) $options |= JSON_PRETTY_PRINT; } - $result = json_encode($data, $options, 512); + $result = json_encode($data, $options, $config->jsonEncodeDepth ?? 512); if (! in_array(json_last_error(), [JSON_ERROR_NONE, JSON_ERROR_RECURSION], true)) { throw FormatException::forInvalidJSON(json_last_error_msg()); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index ac91c4851370..dd5976d07a0f 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -85,6 +85,7 @@ Changes ******* - **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s \G\M\T`` to follow the recommended format in RFC 7231. +- **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. ************ Deprecations diff --git a/user_guide_src/source/outgoing/api_responses.rst b/user_guide_src/source/outgoing/api_responses.rst index 8b5564aa1e98..b47c516c59e5 100644 --- a/user_guide_src/source/outgoing/api_responses.rst +++ b/user_guide_src/source/outgoing/api_responses.rst @@ -49,6 +49,8 @@ format both XML and JSON responses: .. literalinclude:: api_responses/003.php +.. note:: Since ``v4.7.0``, you can change the default JSON encoding depth by editing **app/Config/Format.php** file. The ``$jsonEncodeDepth`` value defines the maximum depth, with a default of ``512``. + This is the array that is used during :doc:`Content Negotiation ` to determine which type of response to return. If no matches are found between what the client requested and what you support, the first format in this array is what will be returned. From 3f2a434c198e7d01e1aba51b4d76dd2b1c4319a5 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 1 Jun 2025 09:48:45 +0800 Subject: [PATCH 13/84] chore: fix psalm error on 4.7 (#9590) --- psalm_autoload.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/psalm_autoload.php b/psalm_autoload.php index 6ebe830423b5..852ef4d6aab3 100644 --- a/psalm_autoload.php +++ b/psalm_autoload.php @@ -10,10 +10,13 @@ 'tests/system/Config/fixtures', ]; $excludeDirs = [ - 'tests/_support/Config', 'tests/_support/View/Cells', 'tests/_support/View/Views', ]; +$excludeFiles = [ + 'tests/_support/Config/Filters.php', + 'tests/_support/Config/Routes.php', +]; foreach ($directories as $directory) { $iterator = new RecursiveIteratorIterator( @@ -38,6 +41,10 @@ continue; } + if (in_array($file->getPathname(), $excludeFiles, true)) { + continue; + } + require_once $file->getPathname(); } } From 06340ce1bebb061aa1f2759f452bab20048409d7 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 3 Jun 2025 19:09:07 +0200 Subject: [PATCH 14/84] refactor: remove deprecated types in random_string() helper (#9592) * refactor: remove deprecated types in random_string() helper * Apply suggestions from code review Co-authored-by: John Paul E. Balandan, CPA * cs fix --------- Co-authored-by: John Paul E. Balandan, CPA --- system/Helpers/text_helper.php | 18 +++++++----------- tests/system/Helpers/TextHelperTest.php | 14 ++++++++++---- user_guide_src/source/changelogs/v4.7.0.rst | 5 +++++ user_guide_src/source/helpers/text_helper.rst | 8 -------- utils/phpstan-baseline/loader.neon | 2 +- .../method.alreadyNarrowedType.neon | 4 ++-- 6 files changed, 25 insertions(+), 26 deletions(-) diff --git a/system/Helpers/text_helper.php b/system/Helpers/text_helper.php index c8067a88a74b..b8298953bdd3 100644 --- a/system/Helpers/text_helper.php +++ b/system/Helpers/text_helper.php @@ -545,10 +545,8 @@ function reduce_multiples(string $str, string $character = ',', bool $trim = fal * * Useful for generating passwords or hashes. * - * @param string $type Type of random string. basic, alpha, alnum, numeric, nozero, md5, sha1, and crypto + * @param string $type Type of random string: alpha, alnum, numeric, nozero, or crypto * @param int $len Number of characters - * - * @deprecated The type 'basic', 'md5', and 'sha1' are deprecated. They are not cryptographically secure. */ function random_string(string $type = 'alnum', int $len = 8): string { @@ -578,12 +576,6 @@ function random_string(string $type = 'alnum', int $len = 8): string return sprintf('%0' . $len . 'd', $rand); - case 'md5': - return md5(uniqid((string) mt_rand(), true)); - - case 'sha1': - return sha1(uniqid((string) mt_rand(), true)); - case 'crypto': if ($len % 2 !== 0) { throw new InvalidArgumentException( @@ -594,8 +586,12 @@ function random_string(string $type = 'alnum', int $len = 8): string return bin2hex(random_bytes($len / 2)); } - // 'basic' type treated as default - return (string) mt_rand(); + throw new InvalidArgumentException( + sprintf( + 'Invalid type "%s". Accepted types: alpha, alnum, numeric, nozero, or crypto.', + $type, + ), + ); } } diff --git a/tests/system/Helpers/TextHelperTest.php b/tests/system/Helpers/TextHelperTest.php index 46748c94a745..a14c722816ae 100644 --- a/tests/system/Helpers/TextHelperTest.php +++ b/tests/system/Helpers/TextHelperTest.php @@ -130,12 +130,8 @@ public function testRandomString(): void $this->assertSame(16, strlen(random_string('numeric', 16))); $this->assertSame(8, strlen(random_string('numeric'))); - $this->assertIsString(random_string('basic')); $this->assertSame(16, strlen($random = random_string('crypto', 16))); $this->assertIsString($random); - - $this->assertSame(32, strlen($random = random_string('md5'))); - $this->assertSame(40, strlen($random = random_string('sha1'))); } /** @@ -151,6 +147,16 @@ public function testRandomStringCryptoOddNumber(): void random_string('crypto', 9); } + public function testRandomStringWithUnsupportedType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Invalid type "basic". Accepted types: alpha, alnum, numeric, nozero, or crypto.', + ); + + random_string('basic'); + } + public function testIncrementString(): void { $this->assertSame('my-test_1', increment_string('my-test')); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index dd5976d07a0f..8ac71bbb08a1 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -31,6 +31,11 @@ Interface Changes Method Signature Changes ======================== +Removed Deprecated Items +======================== + +- **Text Helper:** The deprecated types in ``random_string()`` function: ``basic``, ``md5``, and ``sha1`` has been removed. + ************ Enhancements ************ diff --git a/user_guide_src/source/helpers/text_helper.rst b/user_guide_src/source/helpers/text_helper.rst index d5966347f2ef..5a0fe51d9967 100644 --- a/user_guide_src/source/helpers/text_helper.rst +++ b/user_guide_src/source/helpers/text_helper.rst @@ -30,21 +30,13 @@ The following functions are available: Generates a random string based on the type and length you specify. Useful for creating passwords or generating random hashes. - .. warning:: For types: **basic**, **md5**, and **sha1**, generated strings - are not cryptographically secure. Therefore, these types cannot be used - for cryptographic purposes or purposes requiring unguessable return values. - Since v4.3.3, these types are deprecated. - The first parameter specifies the type of string, the second parameter specifies the length. The following choices are available: - **alpha**: A string with lower and uppercase letters only. - **alnum**: Alphanumeric string with lower and uppercase characters. - - **basic**: [deprecated] A random number based on ``mt_rand()`` (length ignored). - **numeric**: Numeric string. - **nozero**: Numeric string with no zeros. - - **md5**: [deprecated] An encrypted random number based on ``md5()`` (fixed length of 32). - - **sha1**: [deprecated] An encrypted random number based on ``sha1()`` (fixed length of 40). - **crypto**: A random string based on ``random_bytes()``. .. note:: When you use **crypto**, you must set an even number to the second parameter. diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 8dad62c8396b..c8e963df78bc 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 3057 errors +# total 3056 errors includes: - argument.type.neon - assign.propertyType.neon diff --git a/utils/phpstan-baseline/method.alreadyNarrowedType.neon b/utils/phpstan-baseline/method.alreadyNarrowedType.neon index 23132319175b..b947b2cf7f0b 100644 --- a/utils/phpstan-baseline/method.alreadyNarrowedType.neon +++ b/utils/phpstan-baseline/method.alreadyNarrowedType.neon @@ -1,4 +1,4 @@ -# total 24 errors +# total 23 errors parameters: ignoreErrors: @@ -59,7 +59,7 @@ parameters: - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#' - count: 2 + count: 1 path: ../../tests/system/Helpers/TextHelperTest.php - From 15f56bbb8f5129dea40ed74deb76abaad5083937 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Mon, 16 Jun 2025 07:54:41 +0200 Subject: [PATCH 15/84] feat: require double curly braces for placeholders in `regex_match` rule (#9597) * feat: require double curly braces for placeholders in regex_match rule * cs fix --- system/Validation/Validation.php | 16 +++++-- tests/system/Validation/FormatRulesTest.php | 42 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 10 +++++ .../source/libraries/validation.rst | 5 ++- 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index f13af5179112..24b692fcc611 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -823,8 +823,12 @@ protected function fillPlaceholders(array $rules, array $data): array continue; } - // Replace the placeholder in the rule - $ruleSet = str_replace('{' . $field . '}', (string) $data[$field], $ruleSet); + // Replace the placeholder in the current rule string + if (str_starts_with($row, 'regex_match[')) { + $row = str_replace('{{' . $field . '}}', (string) $data[$field], $row); + } else { + $row = str_replace('{' . $field . '}', (string) $data[$field], $row); + } } } } @@ -840,7 +844,13 @@ protected function fillPlaceholders(array $rules, array $data): array */ private function retrievePlaceholders(string $rule, array $data): array { - preg_match_all('/{(.+?)}/', $rule, $matches); + if (str_starts_with($rule, 'regex_match[')) { + // For regex_match rules, only look for double-bracket placeholders + preg_match_all('/\{\{((?:(?![{}]).)+?)\}\}/', $rule, $matches); + } else { + // For all other rules, use single-bracket placeholders + preg_match_all('/{(.+?)}/', $rule, $matches); + } return array_intersect($matches[1], array_keys($data)); } diff --git a/tests/system/Validation/FormatRulesTest.php b/tests/system/Validation/FormatRulesTest.php index 68eff136cb93..b6db5b3272a3 100644 --- a/tests/system/Validation/FormatRulesTest.php +++ b/tests/system/Validation/FormatRulesTest.php @@ -86,6 +86,48 @@ public function testRegexMatchFalse(): void $this->assertFalse($this->validation->run($data)); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/9596 + */ + public function testRegexMatchWithArrayData(): void + { + $data = [ + ['uid' => '2025/06/000001'], + ['uid' => '2025/06/000002'], + ['uid' => '2025/06/000003'], + ['uid' => '2025/06/000004'], + ['uid' => '2025/06/000005'], + ]; + + $this->validation->setRules([ + '*.uid' => 'regex_match[/^(\d{4})\/(0[1-9]|1[0-2])\/\d{6}$/]', + ]); + + $this->assertTrue($this->validation->run($data)); + } + + public function testRegexMatchWithPlaceholder(): void + { + $data = [ + 'code' => 'ABC1234', + 'phone' => '1234567890', + 'prefix' => 'ABC', + 'min_digits' => 10, + 'max_digits' => 15, + ]; + + $this->validation->setRules([ + 'prefix' => 'required|string', + 'min_digits' => 'required|integer', + 'max_digits' => 'required|integer', + 'code' => 'required|regex_match[/^{{prefix}}\d{4}$/]', + 'phone' => 'required|regex_match[/^\d{{{min_digits}},{{max_digits}}}$/]', + ]); + + $result = $this->validation->run($data); + $this->assertTrue($result); + } + #[DataProvider('provideValidUrl')] public function testValidURL(?string $url, bool $isLoose, bool $isStrict): void { diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 8ac71bbb08a1..c3d9cd1d5c12 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -23,6 +23,16 @@ BREAKING Behavior Changes ================ +Validation Rules +---------------- + +Placeholders in the ``regex_match`` validation rule must now use double curly braces. +If you previously used single braces like ``regex_match[/^{placeholder}$/]``, you must +update it to use double braces: ``regex_match[/^{{placeholder}}$/]``. + +This change was introduced to avoid ambiguity with regular expression syntax, +where single curly braces (e.g., ``{1,3}``) are used for quantifiers. + Interface Changes ================= diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 96416355bb22..a6e9e2947762 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -976,7 +976,10 @@ numeric No Fails if field contains anything other than permit_empty No Allows the field to receive an empty array, empty string, null or false. regex_match Yes Fails if field does not match the regular ``regex_match[/regex/]`` - expression. + expression. **Note:** Since v4.7.0, if + you're using a placeholder with this rule, + you must use double braces ``{{...}}`` + instead of single ones ``{...}``. required No Fails if the field is an empty array, empty string, null or false. required_with Yes The field is required when any of the other ``required_with[field1,field2]`` From a5caa4d2ee7b61fa500ab2d749500676c3dca2bf Mon Sep 17 00:00:00 2001 From: Toto Date: Sun, 27 Jul 2025 18:51:10 +0700 Subject: [PATCH 16/84] feat: customizable `.env` directory path (#9631) * feat: add environment directory to load .env file * fix: rename environment directory variable from environmentDirectory to envDirectory Co-authored-by: Michal Sniatala * docs: add information about changing .env file location Co-authored-by: Michal Sniatala * fix: improve environment variable loading with fallback to ROOTPATH * fix: dynamic path for .env file Co-authored-by: Michal Sniatala * fix: improve wording for .env file location recommendation Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: Michal Sniatala Co-authored-by: ddevsr <97607754+ddevsr@users.noreply.github.com> * docs(chngelog): add support for changing the location of the .env file via Paths::$envDirectory property --------- Co-authored-by: Michal Sniatala Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: ddevsr <97607754+ddevsr@users.noreply.github.com> --- app/Config/Paths.php | 12 ++++++++++ system/Boot.php | 3 ++- system/Commands/Encryption/GenerateKey.php | 5 +++-- system/Commands/Utilities/Environment.php | 5 +++-- user_guide_src/source/changelogs/v4.7.0.rst | 1 + .../source/general/managing_apps.rst | 22 +++++++++++++++++++ 6 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/Config/Paths.php b/app/Config/Paths.php index 3dc9c5d93951..61010e10355a 100644 --- a/app/Config/Paths.php +++ b/app/Config/Paths.php @@ -75,4 +75,16 @@ class Paths * is used when no value is provided to `Services::renderer()`. */ public string $viewDirectory = __DIR__ . '/../Views'; + + /** + * --------------------------------------------------------------- + * ENVIRONMENT DIRECTORY NAME + * --------------------------------------------------------------- + * + * This variable must contain the name of the directory where + * the .env file is located. + * Please consider security implications when changing this + * value - the directory should not be publicly accessible. + */ + public string $envDirectory = __DIR__ . '/../../'; } diff --git a/system/Boot.php b/system/Boot.php index ba3675516b16..5b228664146f 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -170,7 +170,8 @@ public static function preload(Paths $paths): void protected static function loadDotEnv(Paths $paths): void { require_once $paths->systemDirectory . '/Config/DotEnv.php'; - (new DotEnv($paths->appDirectory . '/../'))->load(); + $envDirectory = $paths->envDirectory ?? $paths->appDirectory . '/../'; + (new DotEnv($envDirectory))->load(); } protected static function defineEnvironment(): void diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index a3fdbd4393a9..b34b422f7bfe 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -17,6 +17,7 @@ use CodeIgniter\CLI\CLI; use CodeIgniter\Config\DotEnv; use CodeIgniter\Encryption\Encryption; +use Config\Paths; /** * Generates a new encryption key. @@ -101,7 +102,7 @@ public function run(array $params) // force DotEnv to reload the new env vars putenv('encryption.key'); unset($_ENV['encryption.key'], $_SERVER['encryption.key']); - $dotenv = new DotEnv(ROOTPATH); + $dotenv = new DotEnv((new Paths())->envDirectory ?? ROOTPATH); $dotenv->load(); CLI::write('Application\'s new encryption key was successfully set.', 'green'); @@ -155,7 +156,7 @@ protected function confirmOverwrite(array $params): bool protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool { $baseEnv = ROOTPATH . 'env'; - $envFile = ROOTPATH . '.env'; + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; if (! is_file($envFile)) { if (! is_file($baseEnv)) { diff --git a/system/Commands/Utilities/Environment.php b/system/Commands/Utilities/Environment.php index 22794fe9d51d..99a90415ceea 100644 --- a/system/Commands/Utilities/Environment.php +++ b/system/Commands/Utilities/Environment.php @@ -16,6 +16,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\Config\DotEnv; +use Config\Paths; /** * Command to display the current environment, @@ -119,7 +120,7 @@ public function run(array $params) // however we cannot redefine the ENVIRONMENT constant putenv('CI_ENVIRONMENT'); unset($_ENV['CI_ENVIRONMENT'], $_SERVER['CI_ENVIRONMENT']); - (new DotEnv(ROOTPATH))->load(); + (new DotEnv((new Paths())->envDirectory ?? ROOTPATH))->load(); CLI::write(sprintf('Environment is successfully changed to "%s".', $env), 'green'); CLI::write('The ENVIRONMENT constant will be changed in the next script execution.'); @@ -134,7 +135,7 @@ public function run(array $params) private function writeNewEnvironmentToEnvFile(string $newEnv): bool { $baseEnv = ROOTPATH . 'env'; - $envFile = ROOTPATH . '.env'; + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; if (! is_file($envFile)) { if (! is_file($baseEnv)) { diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index c3d9cd1d5c12..843f1f393533 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -101,6 +101,7 @@ Changes - **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s \G\M\T`` to follow the recommended format in RFC 7231. - **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. +- **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. ************ Deprecations diff --git a/user_guide_src/source/general/managing_apps.rst b/user_guide_src/source/general/managing_apps.rst index 35a30153d034..5e71840a98bf 100644 --- a/user_guide_src/source/general/managing_apps.rst +++ b/user_guide_src/source/general/managing_apps.rst @@ -97,3 +97,25 @@ of those: .. literalinclude:: managing_apps/004.php Only when you change the Application Directory, see :ref:`renaming-app-directory` and modify the paths in the **index.php** and **spark**. + +Changing the Location of the .env File +====================================== + +If necessary, you can change the location of the ``.env`` file by adjusting the ``$envDirectory`` +property in ``app/Config/Paths.php``. + +By default, the framework loads environment settings from a ``.env`` file located one level above +the ``app/`` directory (in the ``ROOTPATH``). This is a safe location when your domain is correctly +pointed to the ``public/`` directory, as recommended. + +In practice, however, some applications are served from a subdirectory (e.g., ``http://example.com/myapp``) +rather than from the main domain. In such cases, placing the ``.env`` file within the ``ROOTPATH`` may expose +sensitive configuration data if ``.htaccess`` or other protections are misconfigured. + +To avoid this risk in such setups, it is recommended that you ensure the ``.env`` file is located outside any web-accessible directories. + +.. warning:: + + If you change the location of the ``.env`` file, make absolutely sure it is not publicly accessible. + Exposure of this file could lead to compromised credentials and access to critical services, such as your + database, mail server, or third-party APIs. From ebc25b41806bc70fec6ca0f5ab8d7dcbd2126c44 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 4 Aug 2025 01:56:13 +0800 Subject: [PATCH 17/84] refactor: do not use future-deprecated `DATE_RFC7231` constant (#9657) --- system/Cookie/CookieInterface.php | 2 +- user_guide_src/source/changelogs/v4.7.0.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/system/Cookie/CookieInterface.php b/system/Cookie/CookieInterface.php index 1e5ad2d56728..af86dd4c0c60 100644 --- a/system/Cookie/CookieInterface.php +++ b/system/Cookie/CookieInterface.php @@ -57,7 +57,7 @@ interface CookieInterface * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date * @see https://tools.ietf.org/html/rfc7231#section-7.1.1.2 */ - public const EXPIRES_FORMAT = DATE_RFC7231; + public const EXPIRES_FORMAT = 'D, d M Y H:i:s T'; /** * Returns a unique identifier for the cookie consisting diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 843f1f393533..f8ad0f446f13 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -99,7 +99,7 @@ Message Changes Changes ******* -- **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s \G\M\T`` to follow the recommended format in RFC 7231. +- **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s T`` to follow the recommended format in RFC 7231. - **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. From 500d4ad4ea63b89ad7b4b4f68fb81010773a1e25 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 17 Aug 2025 17:58:54 +0200 Subject: [PATCH 18/84] feat: migrations lock (#9660) * feat: migrations lock * cs fix * update phpstan baseline * add upgrading notes * apply suggestions from code review --- app/Config/Migrations.php | 15 + system/Database/MigrationRunner.php | 372 ++++++++++++------ system/Language/en/Migrations.php | 1 + .../Migrations/MigrationRunnerTest.php | 83 ++++ user_guide_src/source/changelogs/v4.7.0.rst | 5 + user_guide_src/source/dbmgmt/migration.rst | 2 + .../source/installation/upgrade_470.rst | 5 +- utils/phpstan-baseline/empty.notAllowed.neon | 4 +- utils/phpstan-baseline/loader.neon | 2 +- 9 files changed, 368 insertions(+), 121 deletions(-) diff --git a/app/Config/Migrations.php b/app/Config/Migrations.php index 1dec8b9b3a40..eb45e72196c4 100644 --- a/app/Config/Migrations.php +++ b/app/Config/Migrations.php @@ -47,4 +47,19 @@ class Migrations extends BaseConfig * - Y_m_d_His_ */ public string $timestampFormat = 'Y-m-d-His_'; + + /** + * -------------------------------------------------------------------------- + * Enable/Disable Migration Lock + * -------------------------------------------------------------------------- + * + * Locking is disabled by default. + * + * When enabled, it will prevent multiple migration processes + * from running at the same time by using a lock mechanism. + * + * This is useful in production environments to avoid conflicts + * or race conditions during concurrent deployments. + */ + public bool $lock = false; } diff --git a/system/Database/MigrationRunner.php b/system/Database/MigrationRunner.php index fdff86589132..b30f5bea3ebb 100644 --- a/system/Database/MigrationRunner.php +++ b/system/Database/MigrationRunner.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Database; use CodeIgniter\CLI\CLI; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Exceptions\RuntimeException; @@ -101,6 +102,17 @@ class MigrationRunner */ protected $tableChecked = false; + /** + * Lock the migration table. + */ + protected bool $lock = false; + + /** + * Tracks whether we have already ensured + * the lock table exists or not. + */ + protected bool $lockTableChecked = false; + /** * The full path to locate migration files. * @@ -135,6 +147,7 @@ public function __construct(MigrationsConfig $config, $db = null) { $this->enabled = $config->enabled ?? false; $this->table = $config->table ?? 'migrations'; + $this->lock = $config->lock ?? false; $this->namespace = APP_NAMESPACE; @@ -161,52 +174,66 @@ public function latest(?string $group = null) $this->ensureTable(); - if ($group !== null) { - $this->groupFilter = $group; - $this->setGroup($group); - } - - $migrations = $this->findMigrations(); + // Try to acquire lock - exit gracefully if another process is running migrations + if ($this->lock && ! $this->acquireMigrationLock()) { + $message = lang('Migrations.locked'); + $this->cliMessages[] = "\t" . CLI::color($message, 'yellow'); - if ($migrations === []) { return true; } - foreach ($this->getHistory((string) $group) as $history) { - unset($migrations[$this->getObjectUid($history)]); - } + try { + if ($group !== null) { + $this->groupFilter = $group; + $this->setGroup($group); + } - $batch = $this->getLastBatch() + 1; + $migrations = $this->findMigrations(); - foreach ($migrations as $migration) { - if ($this->migrate('up', $migration)) { - if ($this->groupSkip === true) { - $this->groupSkip = false; + if ($migrations === []) { + return true; + } - continue; - } + foreach ($this->getHistory((string) $group) as $history) { + unset($migrations[$this->getObjectUid($history)]); + } - $this->addHistory($migration, $batch); - } else { - $this->regress(-1); + $batch = $this->getLastBatch() + 1; - $message = lang('Migrations.generalFault'); + foreach ($migrations as $migration) { + if ($this->migrate('up', $migration)) { + if ($this->groupSkip === true) { + $this->groupSkip = false; - if ($this->silent) { - $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + continue; + } - return false; - } + $this->addHistory($migration, $batch); + } else { + $this->regress(-1); - throw new RuntimeException($message); + $message = lang('Migrations.generalFault'); + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } + + throw new RuntimeException($message); + } } - } - $data = get_object_vars($this); - $data['method'] = 'latest'; - Events::trigger('migrate', $data); + $data = get_object_vars($this); + $data['method'] = 'latest'; + Events::trigger('migrate', $data); - return true; + return true; + } finally { + if ($this->lock) { + $this->releaseMigrationLock(); + } + } } /** @@ -230,45 +257,75 @@ public function regress(int $targetBatch = 0, ?string $group = null) $this->ensureTable(); - $batches = $this->getBatches(); + // Try to acquire lock - exit gracefully if another process is running migrations + if ($this->lock && ! $this->acquireMigrationLock()) { + $message = lang('Migrations.locked'); + $this->cliMessages[] = "\t" . CLI::color($message, 'yellow'); - if ($targetBatch < 0) { - $targetBatch = $batches[count($batches) - 1 + $targetBatch] ?? 0; - } - - if ($batches === [] && $targetBatch === 0) { return true; } - if ($targetBatch !== 0 && ! in_array($targetBatch, $batches, true)) { - $message = lang('Migrations.batchNotFound') . $targetBatch; - - if ($this->silent) { - $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + try { + $batches = $this->getBatches(); - return false; + if ($targetBatch < 0) { + $targetBatch = $batches[count($batches) - 1 + $targetBatch] ?? 0; } - throw new RuntimeException($message); - } + if ($batches === [] && $targetBatch === 0) { + return true; + } - $tmpNamespace = $this->namespace; + if ($targetBatch !== 0 && ! in_array($targetBatch, $batches, true)) { + $message = lang('Migrations.batchNotFound') . $targetBatch; - $this->namespace = null; - $allMigrations = $this->findMigrations(); + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); - $migrations = []; + return false; + } - while ($batch = array_pop($batches)) { - if ($batch <= $targetBatch) { - break; + throw new RuntimeException($message); } - foreach ($this->getBatchHistory($batch, 'desc') as $history) { - $uid = $this->getObjectUid($history); + $tmpNamespace = $this->namespace; + + $this->namespace = null; + $allMigrations = $this->findMigrations(); + + $migrations = []; + + while ($batch = array_pop($batches)) { + if ($batch <= $targetBatch) { + break; + } + + foreach ($this->getBatchHistory($batch, 'desc') as $history) { + $uid = $this->getObjectUid($history); + + if (! isset($allMigrations[$uid])) { + $message = lang('Migrations.gap') . ' ' . $history->version; + + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + + return false; + } - if (! isset($allMigrations[$uid])) { - $message = lang('Migrations.gap') . ' ' . $history->version; + throw new RuntimeException($message); + } + + $migration = $allMigrations[$uid]; + $migration->history = $history; + $migrations[] = $migration; + } + } + + foreach ($migrations as $migration) { + if ($this->migrate('down', $migration)) { + $this->removeHistory($migration->history); + } else { + $message = lang('Migrations.generalFault'); if ($this->silent) { $this->cliMessages[] = "\t" . CLI::color($message, 'red'); @@ -278,36 +335,20 @@ public function regress(int $targetBatch = 0, ?string $group = null) throw new RuntimeException($message); } - - $migration = $allMigrations[$uid]; - $migration->history = $history; - $migrations[] = $migration; } - } - foreach ($migrations as $migration) { - if ($this->migrate('down', $migration)) { - $this->removeHistory($migration->history); - } else { - $message = lang('Migrations.generalFault'); - - if ($this->silent) { - $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + $data = get_object_vars($this); + $data['method'] = 'regress'; + Events::trigger('migrate', $data); - return false; - } + $this->namespace = $tmpNamespace; - throw new RuntimeException($message); + return true; + } finally { + if ($this->lock) { + $this->releaseMigrationLock(); } } - - $data = get_object_vars($this); - $data['method'] = 'regress'; - Events::trigger('migrate', $data); - - $this->namespace = $tmpNamespace; - - return true; } /** @@ -328,60 +369,74 @@ public function force(string $path, string $namespace, ?string $group = null) $this->ensureTable(); - if ($group !== null) { - $this->groupFilter = $group; - $this->setGroup($group); + // Try to acquire lock - exit gracefully if another process is running migrations + if ($this->lock && ! $this->acquireMigrationLock()) { + $message = lang('Migrations.locked'); + $this->cliMessages[] = "\t" . CLI::color($message, 'yellow'); + + return true; } - $migration = $this->migrationFromFile($path, $namespace); - if (empty($migration)) { - $message = lang('Migrations.notFound'); + try { + if ($group !== null) { + $this->groupFilter = $group; + $this->setGroup($group); + } - if ($this->silent) { - $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + $migration = $this->migrationFromFile($path, $namespace); + if ($migration === false) { + $message = lang('Migrations.notFound'); - return false; - } + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); - throw new RuntimeException($message); - } + return false; + } + + throw new RuntimeException($message); + } - $method = 'up'; - $this->setNamespace($migration->namespace); + $method = 'up'; + $this->setNamespace($migration->namespace); - foreach ($this->getHistory($this->group) as $history) { - if ($this->getObjectUid($history) === $migration->uid) { - $method = 'down'; - $migration->history = $history; - break; + foreach ($this->getHistory($this->group) as $history) { + if ($this->getObjectUid($history) === $migration->uid) { + $method = 'down'; + $migration->history = $history; + break; + } } - } - if ($method === 'up') { - $batch = $this->getLastBatch() + 1; + if ($method === 'up') { + $batch = $this->getLastBatch() + 1; + + if ($this->migrate('up', $migration) && $this->groupSkip === false) { + $this->addHistory($migration, $batch); + + return true; + } - if ($this->migrate('up', $migration) && $this->groupSkip === false) { - $this->addHistory($migration, $batch); + $this->groupSkip = false; + } elseif ($this->migrate('down', $migration)) { + $this->removeHistory($migration->history); return true; } - $this->groupSkip = false; - } elseif ($this->migrate('down', $migration)) { - $this->removeHistory($migration->history); - - return true; - } + $message = lang('Migrations.generalFault'); - $message = lang('Migrations.generalFault'); + if ($this->silent) { + $this->cliMessages[] = "\t" . CLI::color($message, 'red'); - if ($this->silent) { - $this->cliMessages[] = "\t" . CLI::color($message, 'red'); + return false; + } - return false; + throw new RuntimeException($message); + } finally { + if ($this->lock) { + $this->releaseMigrationLock(); + } } - - throw new RuntimeException($message); } /** @@ -817,6 +872,91 @@ public function ensureTable() $this->tableChecked = true; } + /** + * Ensures that we have created our migration + * lock table in the database. + * + * @return string The lock table name + */ + protected function ensureLockTable(): string + { + $lockTable = $this->table . '_lock'; + + if ($this->lockTableChecked || $this->db->tableExists($lockTable)) { + $this->lockTableChecked = true; + + return $lockTable; + } + + $forge = Database::forge($this->db); + + $forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'auto_increment' => true, + ], + 'lock_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + 'unique' => true, + ], + 'acquired_at' => [ + 'type' => 'INTEGER', + 'null' => false, + ], + ]); + + $forge->addPrimaryKey('id'); + $forge->createTable($lockTable, true); + + $this->lockTableChecked = true; + + return $lockTable; + } + + /** + * Acquire exclusive lock on migrations to prevent concurrent execution + * + * @return bool True if lock was acquired, false if another process holds the lock + */ + protected function acquireMigrationLock(): bool + { + $lockTable = $this->ensureLockTable(); + + try { + $this->db->table($lockTable)->insert([ + 'lock_name' => 'migration_process', + 'acquired_at' => Time::now()->getTimestamp(), + ]); + + return $this->db->insertID() > 0; + } catch (DatabaseException) { + // Lock already exists or other error + return false; + } + } + + /** + * Release migration lock + * + * @return bool True if successfully released, false on error + */ + protected function releaseMigrationLock(): bool + { + $lockTable = $this->ensureLockTable(); + + $result = $this->db->table($lockTable) + ->where('lock_name', 'migration_process') + ->delete(); + + if ($result === false) { + log_message('warning', 'Failed to release migration lock'); + } + + return $result; + } + /** * Handles the actual running of a migration. * diff --git a/system/Language/en/Migrations.php b/system/Language/en/Migrations.php index 5d7f3a86542f..a2e66281aed7 100644 --- a/system/Language/en/Migrations.php +++ b/system/Language/en/Migrations.php @@ -22,6 +22,7 @@ 'gap' => 'There is a gap in the migration sequence near version number: ', 'classNotFound' => 'The migration class "%s" could not be found.', 'missingMethod' => 'The migration class is missing an "%s" method.', + 'locked' => 'Migrations already running in another process. Skipping.', // Migration Command 'migHelpLatest' => "\t\tMigrates database to latest available migration.", diff --git a/tests/system/Database/Migrations/MigrationRunnerTest.php b/tests/system/Database/Migrations/MigrationRunnerTest.php index fbe7cbca05f5..510c8169fa34 100644 --- a/tests/system/Database/Migrations/MigrationRunnerTest.php +++ b/tests/system/Database/Migrations/MigrationRunnerTest.php @@ -490,4 +490,87 @@ protected function resetTables($db = null): void $forge->dropTable($table, true); } } + + public function testLatestWithLockingEnabledSucceeds(): void + { + $this->config->lock = true; + + $runner = new MigrationRunner($this->config); + $runner->setNamespace('Tests\Support\MigrationTestMigrations') + ->clearHistory(); + + $this->assertTrue($runner->latest()); + $this->assertTrue($this->db->tableExists('foo')); + + $this->dontSeeInDatabase('migrations_lock', ['lock_name' => 'migration_process']); + } + + public function testRegressWithLockingEnabled(): void + { + $this->config->lock = true; + + $runner = new MigrationRunner($this->config); + $runner->setNamespace('Tests\Support\MigrationTestMigrations') + ->clearHistory(); + + // First run migrations + $runner->latest(); + $this->assertTrue($this->db->tableExists('foo')); + + // Then regress them + $result = $runner->regress(0); + $this->assertTrue($result); + $this->assertFalse($this->db->tableExists('foo')); + } + + public function testLockAcquisitionAndReleaseBasic(): void + { + $this->config->lock = true; + + $runner = new MigrationRunner($this->config); + + $acquireLock = $this->getPrivateMethodInvoker($runner, 'acquireMigrationLock'); + $releaseLock = $this->getPrivateMethodInvoker($runner, 'releaseMigrationLock'); + + // Should acquire lock successfully + $this->assertTrue($acquireLock()); + $this->seeInDatabase('migrations_lock', ['lock_name' => 'migration_process']); + + // Should release successfully + $this->assertTrue($releaseLock()); + $this->dontSeeInDatabase('migrations_lock', ['lock_name' => 'migration_process']); + } + + public function testLockPreventsConcurrentAccess(): void + { + if ($this->db->DBDriver === 'SQLite3' && $this->db->database === ':memory:') { + $this->markTestSkipped('SQLite3 :memory: is not shared between connections.'); + } + + $this->config->lock = true; + + // Create two runners with separate database connections + $runner1 = new MigrationRunner($this->config, $this->db); + $runner2 = new MigrationRunner($this->config, db_connect(null, false)); + + $acquireLock1 = $this->getPrivateMethodInvoker($runner1, 'acquireMigrationLock'); + $releaseLock1 = $this->getPrivateMethodInvoker($runner1, 'releaseMigrationLock'); + $acquireLock2 = $this->getPrivateMethodInvoker($runner2, 'acquireMigrationLock'); + $releaseLock2 = $this->getPrivateMethodInvoker($runner2, 'releaseMigrationLock'); + + // Runner1 should acquire lock successfully + $this->assertTrue($acquireLock1()); + + // Runner2 should fail to acquire lock while runner1 holds it + $this->assertFalse($acquireLock2()); + + // Release lock with runner1 + $this->assertTrue($releaseLock1()); + + // Now runner2 should be able to acquire lock + $this->assertTrue($acquireLock2()); + + // Clean up + $this->assertTrue($releaseLock2()); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index f8ad0f446f13..6ef093803feb 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -76,6 +76,11 @@ Query Builder Forge ----- +Migrations +---------- + +- **MigrationRunner:** Added distributed locking support to prevent concurrent migrations in multi-process environments. Enable with ``Config\Migrations::$lock = true``. + Others ------ diff --git a/user_guide_src/source/dbmgmt/migration.rst b/user_guide_src/source/dbmgmt/migration.rst index 5a4bc0852cee..a9baed5b9c62 100644 --- a/user_guide_src/source/dbmgmt/migration.rst +++ b/user_guide_src/source/dbmgmt/migration.rst @@ -244,6 +244,8 @@ Preference Default Options Description table is always created in the default database group (``$defaultGroup``). **timestampFormat** Y-m-d-His\_ The format to use for timestamps when creating a migration. +**lock** false true / false Enable distributed locking to prevent concurrent migrations + in multi-process environments (e.g., Kubernetes). ==================== ============ ============= ============================================================= *************** diff --git a/user_guide_src/source/installation/upgrade_470.rst b/user_guide_src/source/installation/upgrade_470.rst index 2882870a18f4..10d7aad2d26c 100644 --- a/user_guide_src/source/installation/upgrade_470.rst +++ b/user_guide_src/source/installation/upgrade_470.rst @@ -44,7 +44,8 @@ and it is recommended that you merge the updated versions with your application: Config ------ -- @TODO +- app/Config/Migrations.php + - ``Config\Migrations::$lock`` has been added, with a default value set to ``false``. All Changes =========== @@ -52,4 +53,4 @@ All Changes This is a list of all files in the **project space** that received changes; many will be simple comments or formatting that have no effect on the runtime: -- @TODO +- app/Config/Migrations.php diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index 9e31db0650ba..e41393a87cb6 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 231 errors +# total 230 errors parameters: ignoreErrors: @@ -69,7 +69,7 @@ parameters: - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 5 + count: 4 path: ../../system/Database/MigrationRunner.php - diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index c86df3a4e2e3..f54427206516 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2817 errors +# total 2816 errors includes: - argument.type.neon - assign.propertyType.neon From 13266212e6633d7acdfc3133c5c4795664bae4a4 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 18 Aug 2025 21:43:57 +0800 Subject: [PATCH 19/84] feat: uniform rendering of stack trace from failed DB operations (#9677) * Add `render_backtrace()` function * Add structured trace for all DB drivers * Add changelog --- system/Common.php | 51 +++++++++++++ system/Database/MySQLi/Connection.php | 7 +- system/Database/OCI8/Connection.php | 9 ++- system/Database/Postgre/Connection.php | 9 ++- system/Database/SQLSRV/Connection.php | 26 +++++-- system/Database/SQLite3/Connection.php | 7 +- system/Debug/Exceptions.php | 43 +---------- tests/system/CommonFunctionsTest.php | 10 +++ .../Live/ExecuteLogMessageFormatTest.php | 73 +++++++++++++++++++ tests/system/Debug/ExceptionsTest.php | 16 ---- user_guide_src/source/changelogs/v4.7.0.rst | 2 + utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 7 +- 13 files changed, 187 insertions(+), 75 deletions(-) create mode 100644 tests/system/Database/Live/ExecuteLogMessageFormatTest.php diff --git a/system/Common.php b/system/Common.php index 11c95e1b9c57..d0bc44a9c395 100644 --- a/system/Common.php +++ b/system/Common.php @@ -941,6 +941,57 @@ function remove_invisible_characters(string $str, bool $urlEncoded = true): stri } } +if (! function_exists('render_backtrace')) { + /** + * Renders a backtrace in a nice string format. + * + * @param list + * }> $backtrace + */ + function render_backtrace(array $backtrace): string + { + $backtraces = []; + + foreach ($backtrace as $index => $trace) { + $frame = $trace + ['file' => '[internal function]', 'line' => 0, 'class' => '', 'type' => '', 'args' => []]; + + if ($frame['file'] !== '[internal function]') { + $frame['file'] = sprintf('%s(%s)', $frame['file'], $frame['line']); + } + + unset($frame['line']); + $idx = $index; + $idx = str_pad((string) ++$idx, 2, ' ', STR_PAD_LEFT); + + $args = implode(', ', array_map(static fn ($value): string => match (true) { + is_object($value) => sprintf('Object(%s)', $value::class), + is_array($value) => $value !== [] ? '[...]' : '[]', + $value === null => 'null', + is_resource($value) => sprintf('resource (%s)', get_resource_type($value)), + default => var_export($value, true), + }, $frame['args'])); + + $backtraces[] = sprintf( + '%s %s: %s%s%s(%s)', + $idx, + clean_path($frame['file']), + $frame['class'], + $frame['type'], + $frame['function'], + $args, + ); + } + + return implode("\n", $backtraces); + } +} + if (! function_exists('request')) { /** * Returns the shared Request. diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 2a9f9d3401c8..1857f2cdeaee 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -326,7 +326,12 @@ protected function execute(string $sql) try { return $this->connID->query($this->prepQuery($sql), $this->resultMode); } catch (mysqli_sql_exception $e) { - log_message('error', (string) $e); + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $e->getMessage(), + 'exFile' => clean_path($e->getFile()), + 'exLine' => $e->getLine(), + 'trace' => render_backtrace($e->getTrace()), + ]); if ($this->DBDebug) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 796b412982a9..69c3a0d8eee2 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -231,7 +231,14 @@ protected function execute(string $sql) return $result; } catch (ErrorException $e) { - log_message('error', (string) $e); + $trace = array_slice($e->getTrace(), 2); // remove call to error handler + + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $e->getMessage(), + 'exFile' => clean_path($e->getFile()), + 'exLine' => $e->getLine(), + 'trace' => render_backtrace($trace), + ]); if ($this->DBDebug) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 9b480975de2e..e014d8b6a3e2 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -205,7 +205,14 @@ protected function execute(string $sql) try { return pg_query($this->connID, $sql); } catch (ErrorException $e) { - log_message('error', (string) $e); + $trace = array_slice($e->getTrace(), 2); // remove the call to error handler + + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $e->getMessage(), + 'exFile' => clean_path($e->getFile()), + 'exLine' => $e->getLine(), + 'trace' => render_backtrace($trace), + ]); if ($this->DBDebug) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 53927e036f59..7e9f943ef153 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -155,8 +155,12 @@ public function getAllErrorMessages(): string $errors = []; foreach (sqlsrv_errors() as $error) { - $errors[] = $error['message'] - . ' SQLSTATE: ' . $error['SQLSTATE'] . ', code: ' . $error['code']; + $errors[] = sprintf( + '%s SQLSTATE: %s, code: %s', + $error['message'], + $error['SQLSTATE'], + $error['code'], + ); } return implode("\n", $errors); @@ -522,17 +526,23 @@ public function setDatabase(?string $databaseName = null) */ protected function execute(string $sql) { - $stmt = ($this->scrollable === false || $this->isWriteType($sql)) ? - sqlsrv_query($this->connID, $sql) : - sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]); + $stmt = ($this->scrollable === false || $this->isWriteType($sql)) + ? sqlsrv_query($this->connID, $sql) + : sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]); if ($stmt === false) { - $error = $this->error(); + $trace = debug_backtrace(); + $first = array_shift($trace); - log_message('error', $error['message']); + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $this->getAllErrorMessages(), + 'exFile' => clean_path($first['file']), + 'exLine' => $first['line'], + 'trace' => render_backtrace($trace), + ]); if ($this->DBDebug) { - throw new DatabaseException($error['message']); + throw new DatabaseException($this->getAllErrorMessages()); } } diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 0d3290b38d9d..9a7dc53c6445 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -175,7 +175,12 @@ protected function execute(string $sql) ? $this->connID->exec($sql) : $this->connID->query($sql); } catch (Exception $e) { - log_message('error', (string) $e); + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $e->getMessage(), + 'exFile' => clean_path($e->getFile()), + 'exLine' => $e->getLine(), + 'trace' => render_backtrace($e->getTrace()), + ]); if ($this->DBDebug) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 1a0293372953..04c4429fd783 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -136,7 +136,7 @@ public function exceptionHandler(Throwable $exception) 'routeInfo' => $routeInfo, 'exFile' => clean_path($exception->getFile()), // {file} refers to THIS file 'exLine' => $exception->getLine(), // {line} refers to THIS line - 'trace' => self::renderBacktrace($exception->getTrace()), + 'trace' => render_backtrace($exception->getTrace()), ]); // Get the first exception. @@ -149,7 +149,7 @@ public function exceptionHandler(Throwable $exception) 'message' => $prevException->getMessage(), 'exFile' => clean_path($prevException->getFile()), // {file} refers to THIS file 'exLine' => $prevException->getLine(), // {line} refers to THIS line - 'trace' => self::renderBacktrace($prevException->getTrace()), + 'trace' => render_backtrace($prevException->getTrace()), ]); } } @@ -527,7 +527,7 @@ private function handleDeprecationError(string $message, ?string $file = null, ? 'message' => $message, 'errFile' => clean_path($file ?? ''), 'errLine' => $line ?? 0, - 'trace' => self::renderBacktrace($trace), + 'trace' => render_backtrace($trace), ], ); @@ -646,41 +646,4 @@ public static function highlightFile(string $file, int $lineNumber, int $lines = return '
' . $out . '
'; } - - private static function renderBacktrace(array $backtrace): string - { - $backtraces = []; - - foreach ($backtrace as $index => $trace) { - $frame = $trace + ['file' => '[internal function]', 'line' => '', 'class' => '', 'type' => '', 'args' => []]; - - if ($frame['file'] !== '[internal function]') { - $frame['file'] = sprintf('%s(%s)', $frame['file'], $frame['line']); - } - - unset($frame['line']); - $idx = $index; - $idx = str_pad((string) ++$idx, 2, ' ', STR_PAD_LEFT); - - $args = implode(', ', array_map(static fn ($value): string => match (true) { - is_object($value) => sprintf('Object(%s)', $value::class), - is_array($value) => $value !== [] ? '[...]' : '[]', - $value === null => 'null', - is_resource($value) => sprintf('resource (%s)', get_resource_type($value)), - default => var_export($value, true), - }, $frame['args'])); - - $backtraces[] = sprintf( - '%s %s: %s%s%s(%s)', - $idx, - clean_path($frame['file']), - $frame['class'], - $frame['type'], - $frame['function'], - $args, - ); - } - - return implode("\n", $backtraces); - } } diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 77b8b7b16465..ef773612190e 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -805,4 +805,14 @@ public function testIsWindowsUsingMock(): void $this->assertSame(str_contains(php_uname(), 'Windows'), is_windows()); $this->assertSame(defined('PHP_WINDOWS_VERSION_MAJOR'), is_windows()); } + + public function testRenderBacktrace(): void + { + $trace = (new RuntimeException('Test exception'))->getTrace(); + $renders = explode("\n", render_backtrace($trace)); + + foreach ($renders as $render) { + $this->assertMatchesRegularExpression('/^\s*\d* .+(?:\(\d+\))?: \S+(?:(?:\->|::)\S+)?\(.*\)$/', $render); + } + } } diff --git a/tests/system/Database/Live/ExecuteLogMessageFormatTest.php b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php new file mode 100644 index 000000000000..ca79e4f46fcc --- /dev/null +++ b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\TestLogger; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExecuteLogMessageFormatTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + self::setPrivateProperty(TestLogger::class, 'op_logs', []); + } + + protected function tearDown(): void + { + parent::tearDown(); + + self::setPrivateProperty(TestLogger::class, 'op_logs', []); + } + + public function testLogMessageWhenExecuteFailsShowFullStructuredBacktrace(): void + { + $db = Database::connect('tests', false); + self::setPrivateProperty($db, 'DBDebug', false); + + $sql = 'SELECT * FROM some_table WHERE id = ? AND status = ? AND author = ?'; + $db->query($sql, [3, 'live', 'Rick']); + + $pattern = match ($db->DBDriver) { + 'MySQLi' => '/Table \'test\.some_table\' doesn\'t exist/', + 'Postgre' => '/pg_query\(\): Query failed: ERROR: relation "some_table" does not exist/', + 'SQLite3' => '/Unable to prepare statement:\s(\d+,\s)?no such table: some_table/', + 'OCI8' => '/oci_execute\(\): ORA-00942: table or view does not exist/', + 'SQLSRV' => '/\[Microsoft\]\[ODBC Driver \d+ for SQL Server\]\[SQL Server\]Invalid object name \'some_table\'/', + default => '/Unknown DB error/', + }; + $messageFromLogs = explode("\n", self::getPrivateProperty(TestLogger::class, 'op_logs')[0]['message']); + + $this->assertMatchesRegularExpression($pattern, array_shift($messageFromLogs)); + + if ($db->DBDriver === 'Postgre') { + $messageFromLogs = array_slice($messageFromLogs, 2); + } elseif ($db->DBDriver === 'OCI8') { + $messageFromLogs = array_slice($messageFromLogs, 1); + } + + $this->assertMatchesRegularExpression('/^in \S+ on line \d+\.$/', array_shift($messageFromLogs)); + + foreach ($messageFromLogs as $line) { + $this->assertMatchesRegularExpression('/^\s*\d* .+(?:\(\d+\))?: \S+(?:(?:\->|::)\S+)?\(.*\)$/', $line); + } + } +} diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index 6ce2fda06c88..a961871c359e 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -128,22 +128,6 @@ public function testDetermineCodes(): void $this->assertSame([500, EXIT_DATABASE], $determineCodes(new DatabaseException('This.'))); } - public function testRenderBacktrace(): void - { - $renderer = self::getPrivateMethodInvoker(Exceptions::class, 'renderBacktrace'); - $exception = new RuntimeException('This.'); - - $renderedBacktrace = $renderer($exception->getTrace()); - $renderedBacktrace = explode("\n", $renderedBacktrace); - - foreach ($renderedBacktrace as $trace) { - $this->assertMatchesRegularExpression( - '/^\s*\d* .+(?:\(\d+\))?: \S+(?:(?:\->|::)\S+)?\(.*\)$/', - $trace, - ); - } - } - public function testMaskSensitiveData(): void { $maskSensitiveData = self::getPrivateMethodInvoker($this->exception, 'maskSensitiveData'); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 6ef093803feb..1070eb9380cd 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -70,6 +70,8 @@ Testing Database ======== +- **Exception Logging:** All DB drivers now log database exceptions uniformly. Previously, each driver has its own log format. + Query Builder ------------- diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index c86df3a4e2e3..f54427206516 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2817 errors +# total 2816 errors includes: - argument.type.neon - assign.propertyType.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 43c97e9ce625..d86e5518e7d1 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1408 errors +# total 1407 errors parameters: ignoreErrors: @@ -2047,11 +2047,6 @@ parameters: count: 1 path: ../../system/Debug/Exceptions.php - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:renderBacktrace\(\) has parameter \$backtrace with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - message: '#^Property CodeIgniter\\Debug\\Iterator\:\:\$results type has no value type specified in iterable type array\.$#' count: 1 From dfc34fdbd108918510de78c509bb340536554b62 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Tue, 19 Aug 2025 21:24:51 +0700 Subject: [PATCH 20/84] refactor: remove `curl_close` as has no effect since PHP 8.0 (#9683) --- system/HTTP/CURLRequest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index f0a4b8153716..7014ca418a43 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -733,8 +733,6 @@ protected function sendRequest(array $curlOptions = []): string throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch)); } - curl_close($ch); - return $output; } From ae08d1416e0dafcc4e727261b76a2419ca40802d Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Tue, 19 Aug 2025 21:27:44 +0700 Subject: [PATCH 21/84] refactor: remove `finfo_close` has no effect since PHP 8.0 (#9684) --- system/Files/File.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/system/Files/File.php b/system/Files/File.php index be7f84f8a20b..a493b59e2a32 100644 --- a/system/Files/File.php +++ b/system/Files/File.php @@ -133,11 +133,9 @@ public function getMimeType(): string return $this->originalMimeType ?? 'application/octet-stream'; // @codeCoverageIgnore } - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mimeType = finfo_file($finfo, $this->getRealPath() ?: $this->__toString()); - finfo_close($finfo); + $finfo = finfo_open(FILEINFO_MIME_TYPE); - return $mimeType; + return finfo_file($finfo, $this->getRealPath() ?: $this->__toString()); } /** From 3c10cc12197af43ec6d15b11754b44249fa881d3 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Tue, 19 Aug 2025 22:34:27 +0700 Subject: [PATCH 22/84] refactor: remove `imagedestroy` has no effect since PHP 8.0 (#9688) * refactor: remove `imagedestroy` has no effect since PHP 8.0 * fix: set null properties resource --- system/Images/Handlers/GDHandler.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/system/Images/Handlers/GDHandler.php b/system/Images/Handlers/GDHandler.php index 12ea4e1a72e2..01c05384c744 100644 --- a/system/Images/Handlers/GDHandler.php +++ b/system/Images/Handlers/GDHandler.php @@ -56,9 +56,6 @@ protected function _rotate(int $angle): bool // Rotate it! $destImg = imagerotate($srcImg, $angle, $white); - // Kill the file handles - imagedestroy($srcImg); - $this->resource = $destImg; return true; @@ -87,9 +84,6 @@ protected function _flatten(int $red = 255, int $green = 255, int $blue = 255) imagefilledrectangle($dest, 0, 0, $this->width, $this->height, $matte); imagecopy($dest, $srcImg, 0, 0, 0, 0, $this->width, $this->height); - // Kill the file handles - imagedestroy($srcImg); - $this->resource = $dest; return $this; @@ -192,7 +186,6 @@ protected function process(string $action) $copy($dest, $src, 0, 0, (int) $this->xAxis, (int) $this->yAxis, $this->width, $this->height, $origWidth, $origHeight); - imagedestroy($src); $this->resource = $dest; return $this; @@ -281,7 +274,7 @@ public function save(?string $target = null, int $quality = 90): bool throw ImageException::forInvalidImageCreate(); } - imagedestroy($this->resource); + $this->resource = null; chmod($target, $this->filePermissions); From 97a54625a45173f07de4ee2be53a7185327371d2 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:46:21 +0700 Subject: [PATCH 23/84] chore: add `PHP 8.5` to Github Action (#9667) * chore: added PHP 8.5 to Github Action * ignore platform on utils * remove conditional php-cs-fixer * Fix using include * Fix for psalm --------- Co-authored-by: John Paul E. Balandan, CPA --- .github/workflows/reusable-phpunit-test.yml | 1 + .github/workflows/test-coding-standards.yml | 6 ++++-- .github/workflows/test-phpunit.yml | 12 ++++++++++++ .github/workflows/test-psalm.yml | 13 ++++++++++--- .github/workflows/test-rector.yml | 13 +++++++++---- composer.json | 3 ++- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index bfb641097359..013990fd2e68 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -209,6 +209,7 @@ jobs: DB: ${{ inputs.db-platform }} TACHYCARDIA_MONITOR_GA: ${{ inputs.enable-profiling && 'enabled' || '' }} TERM: xterm-256color + continue-on-error: ${{ inputs.php-version == '8.5' }} - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index a92266f7e3d0..391ecd418a1c 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -29,7 +29,9 @@ jobs: matrix: php-version: - '8.1' - - '8.4' + include: + - php-version: '8.5' + composer-option: '--ignore-platform-req=php' steps: - name: Checkout base branch for PR @@ -61,7 +63,7 @@ jobs: ${{ runner.os }}- - name: Install dependencies - run: composer update --ansi --no-interaction + run: composer update --ansi --no-interaction ${{ matrix.composer-option }} - name: Run lint run: composer cs diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index 62a8ab0e4338..045570cb27ad 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -60,6 +60,9 @@ jobs: - '8.2' - '8.3' - '8.4' + include: + - php-version: '8.5' + composer-option: '--ignore-platform-req=php' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: @@ -87,6 +90,7 @@ jobs: - '8.2' - '8.3' - '8.4' + - '8.5' db-platform: - MySQLi - OCI8 @@ -99,6 +103,8 @@ jobs: - php-version: '8.1' db-platform: MySQLi mysql-version: '5.7' + - php-version: '8.5' + composer-option: '--ignore-platform-req=php' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: @@ -127,6 +133,9 @@ jobs: - '8.2' - '8.3' - '8.4' + include: + - php-version: '8.5' + composer-option: '--ignore-platform-req=php' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: @@ -153,6 +162,9 @@ jobs: - '8.2' - '8.3' - '8.4' + include: + - php-version: '8.5' + composer-option: '--ignore-platform-req=php' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index c501714f85e7..cc6c3afaade4 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -24,7 +24,12 @@ jobs: build: name: Psalm Analysis runs-on: ubuntu-latest - if: (! contains(github.event.head_commit.message, '[ci skip]')) + + strategy: + fail-fast: false + matrix: + php-version: + - '8.1' steps: - name: Checkout base branch for PR @@ -39,7 +44,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: ${{ matrix.php-version }} extensions: intl, json, mbstring, xml, mysqli, oci8, pgsql, sqlsrv, sqlite3 coverage: none env: @@ -66,7 +71,9 @@ jobs: restore-keys: ${{ runner.os }}-psalm- - name: Install dependencies - run: composer update --ansi --no-interaction + run: | + composer require sebastian/diff:^5.0 --ansi --working-dir utils + composer update --ansi --no-interaction - name: Run Psalm analysis run: utils/vendor/bin/psalm diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index be469a27d253..3a9f6d649470 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -40,12 +40,17 @@ permissions: jobs: build: - name: PHP ${{ matrix.php-versions }} Analyze code (Rector) + name: PHP ${{ matrix.php-version }} Analyze code (Rector) runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: - php-versions: ['8.1', '8.4'] + php-version: + - '8.1' + include: + - php-version: '8.5' + composer-option: '--ignore-platform-req=php' + steps: - name: Checkout base branch for PR if: github.event_name == 'pull_request' @@ -59,7 +64,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php-versions }} + php-version: ${{ matrix.php-version }} extensions: intl - name: Use latest Composer @@ -79,7 +84,7 @@ jobs: restore-keys: ${{ runner.os }}-composer- - name: Install dependencies - run: composer update --ansi --no-interaction + run: composer update --ansi --no-interaction ${{ matrix.composer-option }} - name: Rector Cache uses: actions/cache@v4 diff --git a/composer.json b/composer.json index bebb1f55752b..f5fdcd0d0420 100644 --- a/composer.json +++ b/composer.json @@ -89,7 +89,8 @@ "CodeIgniter\\ComposerScripts::postUpdate" ], "post-autoload-dump": [ - "@composer update --ansi --working-dir=utils" + "@php -r \"if (PHP_VERSION_ID >= 80500) { echo '@todo Remove \"--ignore-platform-req=php\" once deps catch up.', PHP_EOL; }\"", + "@composer update --ansi --working-dir=utils --ignore-platform-req=php" ], "analyze": [ "Composer\\Config::disableProcessTimeout", From 797eebf9d1c1451193c1ea65a9ce2402149e52c0 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Tue, 2 Sep 2025 13:24:33 +0700 Subject: [PATCH 24/84] refactor: deprecated PHP 8.5 constant FILTER_DEFAULT for filter_*() --- system/HTTP/IncomingRequest.php | 8 ++++---- system/HTTP/RequestTrait.php | 4 ++-- system/Helpers/cookie_helper.php | 2 +- tests/system/HTTP/IncomingRequestTest.php | 7 +++++++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 72b1ce0ca5df..260f7457fadc 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -572,10 +572,10 @@ public function getJsonVar($index = null, bool $assoc = false, ?int $filter = nu return null; } - $filter ??= FILTER_DEFAULT; + $filter ??= FILTER_UNSAFE_RAW; $flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0); - if ($filter !== FILTER_DEFAULT + if ($filter !== FILTER_UNSAFE_RAW || ( (is_numeric($flags) && $flags !== 0) || is_array($flags) && $flags !== [] @@ -656,12 +656,12 @@ public function getRawInputVar($index = null, ?int $filter = null, $flags = null [$output, $data] = [$data, null]; } - $filter ??= FILTER_DEFAULT; + $filter ??= FILTER_UNSAFE_RAW; $flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0); if (is_array($output) && ( - $filter !== FILTER_DEFAULT + $filter !== FILTER_UNSAFE_RAW || ( (is_numeric($flags) && $flags !== 0) || is_array($flags) && $flags !== [] diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index 3c3da161a3be..e0efd46324a1 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -260,7 +260,7 @@ public function fetchGlobal(string $name, $index = null, ?int $filter = null, $f } // Null filters cause null values to return. - $filter ??= FILTER_DEFAULT; + $filter ??= FILTER_UNSAFE_RAW; $flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0); // Return all values when $index is null @@ -312,7 +312,7 @@ public function fetchGlobal(string $name, $index = null, ?int $filter = null, $f if (is_array($value) && ( - $filter !== FILTER_DEFAULT + $filter !== FILTER_UNSAFE_RAW || ( (is_numeric($flags) && $flags !== 0) || is_array($flags) && $flags !== [] diff --git a/system/Helpers/cookie_helper.php b/system/Helpers/cookie_helper.php index fb1c1f366fed..e3b10a1b60f6 100644 --- a/system/Helpers/cookie_helper.php +++ b/system/Helpers/cookie_helper.php @@ -88,7 +88,7 @@ function get_cookie($index, bool $xssClean = false, ?string $prefix = '') } $request = service('request'); - $filter = $xssClean ? FILTER_SANITIZE_FULL_SPECIAL_CHARS : FILTER_DEFAULT; + $filter = $xssClean ? FILTER_SANITIZE_FULL_SPECIAL_CHARS : FILTER_UNSAFE_RAW; return $request->getCookie($prefix . $index, $filter); } diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 1240d0885b23..eb6aa04d3a6a 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -607,6 +607,13 @@ public static function provideCanGrabGetRawInputVar(): iterable null, null, ], + [ + 'username=admin001&role=administrator&usepass=0', + 'username', + 'admin001', + null, + FILTER_UNSAFE_RAW, + ], [ 'username=admin001&role=administrator&usepass=0', ['role', 'usepass'], From cbea67a010870ef8816ca8c1f22daa599f4a10ec Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Tue, 2 Sep 2025 17:49:40 +0700 Subject: [PATCH 25/84] chore: added rules RenameConstantRector on Rector --- rector.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rector.php b/rector.php index c814a7402c4d..4d06cad5598f 100644 --- a/rector.php +++ b/rector.php @@ -38,6 +38,7 @@ use Rector\PHPUnit\CodeQuality\Rector\Class_\RemoveDataProviderParamKeysRector; use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector; use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector; +use Rector\Renaming\Rector\ConstFetch\RenameConstantRector; use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; use Rector\Strict\Rector\If_\BooleanInIfConditionRuleFixerRector; use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector; @@ -205,4 +206,7 @@ // keep '\\' prefix string on string '\Foo\Bar' StringClassNameToClassConstantRector::SHOULD_KEEP_PRE_SLASH => true, ]) + ->withConfiguredRule(RenameConstantRector::class, [ + 'FILTER_DEFAULT' => 'FILTER_UNSAFE_RAW', + ]) ->withCodeQualityLevel(34); From fca548dc33a35736fbbd114c7786e6fb92b607f8 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:49:22 +0700 Subject: [PATCH 26/84] chore: bump minimum required `PHP 8.2` (#9701) * chore: bump minimum required PHP 8.2 * chore: set minimum at workflow * docs: set minimum PHP 8.2 * refactor: set minimum PHP 8.2 * refactor: artifact upload with mysql_version when exists * refactor: target php setlist PHP 8.2 to Rector * revert docs on UploadedFiles * starter using ^4.7 * run rector & phpstan * revert change responsetrait and baseline * refactor: remove TODO * set minimum PHP 8.2 in utils * docs: requirement * docs: notes EOL --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/deploy-apidocs.yml | 2 +- .github/workflows/deploy-userguide-latest.yml | 2 +- .github/workflows/reusable-phpunit-test.yml | 2 +- .github/workflows/test-autoreview.yml | 6 ++-- .github/workflows/test-coding-standards.yml | 2 +- .github/workflows/test-deptrac.yml | 2 +- .github/workflows/test-phpstan.yml | 2 +- .github/workflows/test-phpunit.yml | 6 +--- .github/workflows/test-psalm.yml | 2 +- .github/workflows/test-rector.yml | 2 +- README.md | 7 ++-- admin/framework/README.md | 7 ++-- admin/framework/composer.json | 2 +- admin/starter/.github/workflows/phpunit.yml | 2 +- admin/starter/README.md | 7 ++-- admin/starter/composer.json | 4 +-- composer.json | 2 +- contributing/pull_request.md | 2 +- phpstan.neon.dist | 2 +- public/index.php | 2 +- rector.php | 2 +- spark | 2 +- system/Cache/FactoriesCache.php | 4 +-- .../Utilities/Routes/AutoRouteCollector.php | 4 +-- .../AutoRouterImproved/AutoRouteCollector.php | 14 ++++---- .../ControllerMethodReader.php | 10 +++--- .../Utilities/Routes/ControllerFinder.php | 6 ++-- .../Routes/ControllerMethodReader.php | 4 +-- .../Utilities/Routes/FilterCollector.php | 4 +-- .../Utilities/Routes/FilterFinder.php | 6 ++-- system/DataConverter/DataConverter.php | 12 +++---- system/Database/ConnectionInterface.php | 2 +- system/Database/Database.php | 4 +++ system/Debug/Exceptions.php | 11 ------- system/HTTP/SiteURIFactory.php | 4 +-- system/Router/DefinedRouteCollector.php | 4 +-- user_guide_src/source/changelogs/v4.7.0.rst | 2 +- .../installation/installing_composer.rst | 6 ++-- user_guide_src/source/intro/requirements.rst | 8 +++-- utils/composer.json | 2 +- .../booleanNot.exprNotBoolean.neon | 8 +++++ utils/phpstan-baseline/loader.neon | 3 +- utils/phpstan-baseline/property.notFound.neon | 32 ++++++++++++++++++- ...nderscoreToCamelCaseVariableNameRector.php | 2 +- 45 files changed, 128 insertions(+), 95 deletions(-) create mode 100644 utils/phpstan-baseline/booleanNot.exprNotBoolean.neon diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 1f6dc36b395d..1f67ebe082f5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,10 +25,10 @@ body: description: Which PHP versions did you run your code? multiple: true options: - - '8.1' - '8.2' - '8.3' - '8.4' + - '8.5' validations: required: true diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index 08ddb313a991..4b3efe828ba4 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -43,7 +43,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' tools: phive coverage: none diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index 39c1c92833b1..4b6a4579d40c 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -30,7 +30,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' coverage: none - name: Setup Python diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 013990fd2e68..37aeeef4a83f 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -170,7 +170,7 @@ jobs: - name: Setup global environment variables run: | echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}" >> $GITHUB_ENV + echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}${{ inputs.mysql-version || '' }}" >> $GITHUB_ENV - name: Cache dependencies uses: actions/cache@v4 diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index a3b9a6e11409..4d452edd24ed 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -26,8 +26,8 @@ jobs: name: Automatic Code Review uses: ./.github/workflows/reusable-serviceless-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: - job-name: PHP 8.1 - php-version: '8.1' + job-name: PHP 8.2 + php-version: '8.2' job-id: auto-review-tests group-name: AutoReview @@ -47,7 +47,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' - name: Install dependencies run: composer update diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index 391ecd418a1c..31f28bdeed8f 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: php-version: - - '8.1' + - '8.2' include: - php-version: '8.5' composer-option: '--ignore-platform-req=php' diff --git a/.github/workflows/test-deptrac.yml b/.github/workflows/test-deptrac.yml index 7ce0052a2732..7a5dea0c3164 100644 --- a/.github/workflows/test-deptrac.yml +++ b/.github/workflows/test-deptrac.yml @@ -48,7 +48,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' tools: composer extensions: intl, json, mbstring, gd, mysqlnd, xdebug, xml, sqlite3 diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index eeb0793c182e..a7a0047ec8be 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -57,7 +57,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' extensions: intl coverage: none diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index 045570cb27ad..b9a8666cbcb4 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -56,7 +56,6 @@ jobs: strategy: matrix: php-version: - - '8.1' - '8.2' - '8.3' - '8.4' @@ -86,7 +85,6 @@ jobs: fail-fast: false matrix: php-version: - - '8.1' - '8.2' - '8.3' - '8.4' @@ -100,7 +98,7 @@ jobs: mysql-version: - '8.0' include: - - php-version: '8.1' + - php-version: '8.2' db-platform: MySQLi mysql-version: '5.7' - php-version: '8.5' @@ -129,7 +127,6 @@ jobs: strategy: matrix: php-version: - - '8.1' - '8.2' - '8.3' - '8.4' @@ -158,7 +155,6 @@ jobs: strategy: matrix: php-version: - - '8.1' - '8.2' - '8.3' - '8.4' diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index cc6c3afaade4..d46170fc8487 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -29,7 +29,7 @@ jobs: fail-fast: false matrix: php-version: - - '8.1' + - '8.2' steps: - name: Checkout base branch for PR diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index 3a9f6d649470..d780a9763f09 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -46,7 +46,7 @@ jobs: fail-fast: false matrix: php-version: - - '8.1' + - '8.2' include: - php-version: '8.5' composer-option: '--ignore-platform-req=php' diff --git a/README.md b/README.md index 77419c2a2348..d7d633ac28b7 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Made with [contrib.rocks](https://contrib.rocks). ## Server Requirements -PHP version 8.1 or higher is required, with the following extensions installed: +PHP version 8.2 or higher is required, with the following extensions installed: - [intl](http://php.net/manual/en/intl.requirements.php) - [mbstring](http://php.net/manual/en/mbstring.installation.php) @@ -95,8 +95,9 @@ PHP version 8.1 or higher is required, with the following extensions installed: > [!WARNING] > - The end of life date for PHP 7.4 was November 28, 2022. > - The end of life date for PHP 8.0 was November 26, 2023. -> - If you are still using PHP 7.4 or 8.0, you should upgrade immediately. -> - The end of life date for PHP 8.1 will be December 31, 2025. +> - The end of life date for PHP 8.1 was December 31, 2025. +> - If you are still using below PHP 8.2, you should upgrade immediately. +> - The end of life date for PHP 8.2 will be December 31, 2026. Additionally, make sure that the following extensions are enabled in your PHP: diff --git a/admin/framework/README.md b/admin/framework/README.md index a23783ac316a..dd39ef950968 100644 --- a/admin/framework/README.md +++ b/admin/framework/README.md @@ -42,7 +42,7 @@ Please read the [*Contributing to CodeIgniter*](https://github.com/codeigniter4/ ## Server Requirements -PHP version 8.1 or higher is required, with the following extensions installed: +PHP version 8.2 or higher is required, with the following extensions installed: - [intl](http://php.net/manual/en/intl.requirements.php) - [mbstring](http://php.net/manual/en/mbstring.installation.php) @@ -50,8 +50,9 @@ PHP version 8.1 or higher is required, with the following extensions installed: > [!WARNING] > - The end of life date for PHP 7.4 was November 28, 2022. > - The end of life date for PHP 8.0 was November 26, 2023. -> - If you are still using PHP 7.4 or 8.0, you should upgrade immediately. -> - The end of life date for PHP 8.1 will be December 31, 2025. +> - The end of life date for PHP 8.1 was December 31, 2025. +> - If you are still using below PHP 8.2, you should upgrade immediately. +> - The end of life date for PHP 8.2 will be December 31, 2026. Additionally, make sure that the following extensions are enabled in your PHP: diff --git a/admin/framework/composer.json b/admin/framework/composer.json index 499a97eae337..aef544f51126 100644 --- a/admin/framework/composer.json +++ b/admin/framework/composer.json @@ -10,7 +10,7 @@ "slack": "https://codeigniterchat.slack.com" }, "require": { - "php": "^8.1", + "php": "^8.2", "ext-intl": "*", "ext-mbstring": "*", "laminas/laminas-escaper": "^2.17", diff --git a/admin/starter/.github/workflows/phpunit.yml b/admin/starter/.github/workflows/phpunit.yml index 2be22ec16095..fb5e3a2cb7e0 100644 --- a/admin/starter/.github/workflows/phpunit.yml +++ b/admin/starter/.github/workflows/phpunit.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - php-versions: ['8.1', '8.3'] + php-versions: ['8.2', '8.4'] runs-on: ubuntu-latest diff --git a/admin/starter/README.md b/admin/starter/README.md index d14b4c9c804c..45f98af6033e 100644 --- a/admin/starter/README.md +++ b/admin/starter/README.md @@ -50,7 +50,7 @@ Problems with it can be raised on our forum, or as issues in the main repository ## Server Requirements -PHP version 8.1 or higher is required, with the following extensions installed: +PHP version 8.2 or higher is required, with the following extensions installed: - [intl](http://php.net/manual/en/intl.requirements.php) - [mbstring](http://php.net/manual/en/mbstring.installation.php) @@ -58,8 +58,9 @@ PHP version 8.1 or higher is required, with the following extensions installed: > [!WARNING] > - The end of life date for PHP 7.4 was November 28, 2022. > - The end of life date for PHP 8.0 was November 26, 2023. -> - If you are still using PHP 7.4 or 8.0, you should upgrade immediately. -> - The end of life date for PHP 8.1 will be December 31, 2025. +> - The end of life date for PHP 8.1 was December 31, 2025. +> - If you are still using below PHP 8.2, you should upgrade immediately. +> - The end of life date for PHP 8.2 will be December 31, 2026. Additionally, make sure that the following extensions are enabled in your PHP: diff --git a/admin/starter/composer.json b/admin/starter/composer.json index 38a51e29fb64..d47149e0287f 100644 --- a/admin/starter/composer.json +++ b/admin/starter/composer.json @@ -10,8 +10,8 @@ "slack": "https://codeigniterchat.slack.com" }, "require": { - "php": "^8.1", - "codeigniter4/framework": "^4.0" + "php": "^8.2", + "codeigniter4/framework": "^4.7" }, "require-dev": { "fakerphp/faker": "^1.9", diff --git a/composer.json b/composer.json index f5fdcd0d0420..599720cb1d73 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "slack": "https://codeigniterchat.slack.com" }, "require": { - "php": "^8.1", + "php": "^8.2", "ext-intl": "*", "ext-mbstring": "*", "laminas/laminas-escaper": "^2.17", diff --git a/contributing/pull_request.md b/contributing/pull_request.md index 8e3b5ed9f419..8ad9491fc6bf 100644 --- a/contributing/pull_request.md +++ b/contributing/pull_request.md @@ -158,7 +158,7 @@ See [Contribution CSS](./css.md). ### Compatibility -CodeIgniter4 requires [PHP 8.1](https://php.net/releases/8_1_0.php). +CodeIgniter4 requires [PHP 8.2](https://php.net/releases/8_2_0.php). ### Backwards Compatibility diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e6cc288cc678..f0d8a8b691b8 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,7 +2,7 @@ includes: - utils/phpstan-baseline/loader.neon parameters: - phpVersion: 80100 + phpVersion: 80200 tmpDir: build/phpstan level: 6 bootstrapFiles: diff --git a/public/index.php b/public/index.php index a0a20db43dbf..8e834f2c863d 100644 --- a/public/index.php +++ b/public/index.php @@ -9,7 +9,7 @@ *--------------------------------------------------------------- */ -$minPhpVersion = '8.1'; // If you update this, don't forget to update `spark`. +$minPhpVersion = '8.2'; // If you update this, don't forget to update `spark`. if (version_compare(PHP_VERSION, $minPhpVersion, '<')) { $message = sprintf( 'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s', diff --git a/rector.php b/rector.php index 4d06cad5598f..67941d76bcc2 100644 --- a/rector.php +++ b/rector.php @@ -54,7 +54,7 @@ use Utils\Rector\UnderscoreToCamelCaseVariableNameRector; return RectorConfig::configure() - ->withPhpSets(php81: true) + ->withPhpSets(php82: true) ->withPreparedSets(deadCode: true, instanceOf: true, strictBooleans: true, phpunitCodeQuality: true) ->withComposerBased(phpunit: true) ->withParallel(120, 8, 10) diff --git a/spark b/spark index e7871ea8e682..61bc572b9759 100755 --- a/spark +++ b/spark @@ -38,7 +38,7 @@ if (str_starts_with(PHP_SAPI, 'cgi')) { *--------------------------------------------------------------- */ -$minPhpVersion = '8.1'; // If you update this, don't forget to update `public/index.php`. +$minPhpVersion = '8.2'; // If you update this, don't forget to update `public/index.php`. if (version_compare(PHP_VERSION, $minPhpVersion, '<')) { $message = sprintf( 'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s', diff --git a/system/Cache/FactoriesCache.php b/system/Cache/FactoriesCache.php index 6e49996c5d45..7f0293b62d9e 100644 --- a/system/Cache/FactoriesCache.php +++ b/system/Cache/FactoriesCache.php @@ -16,9 +16,9 @@ use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; use CodeIgniter\Config\Factories; -final class FactoriesCache +final readonly class FactoriesCache { - private readonly CacheInterface|FileVarExportHandler $cache; + private CacheInterface|FileVarExportHandler $cache; public function __construct(CacheInterface|FileVarExportHandler|null $cache = null) { diff --git a/system/Commands/Utilities/Routes/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouteCollector.php index 3b91d365ac80..3c8eb44279c7 100644 --- a/system/Commands/Utilities/Routes/AutoRouteCollector.php +++ b/system/Commands/Utilities/Routes/AutoRouteCollector.php @@ -18,12 +18,12 @@ * * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouteCollectorTest */ -final class AutoRouteCollector +final readonly class AutoRouteCollector { /** * @param string $namespace namespace to search */ - public function __construct(private readonly string $namespace, private readonly string $defaultController, private readonly string $defaultMethod) + public function __construct(private string $namespace, private string $defaultController, private string $defaultMethod) { } diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php index f4a5204fb49b..e723317431b6 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php @@ -21,7 +21,7 @@ * * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\AutoRouteCollectorTest */ -final class AutoRouteCollector +final readonly class AutoRouteCollector { /** * @param string $namespace namespace to search @@ -31,12 +31,12 @@ final class AutoRouteCollector * @param string $prefix URI prefix for Module Routing */ public function __construct( - private readonly string $namespace, - private readonly string $defaultController, - private readonly string $defaultMethod, - private readonly array $httpMethods, - private readonly array $protectedControllers, - private readonly string $prefix = '', + private string $namespace, + private string $defaultController, + private string $defaultMethod, + private array $httpMethods, + private array $protectedControllers, + private string $prefix = '', ) { } diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php index a0a98afc829a..599e90f4d2f3 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php @@ -22,18 +22,18 @@ * * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\ControllerMethodReaderTest */ -final class ControllerMethodReader +final readonly class ControllerMethodReader { - private readonly bool $translateURIDashes; - private readonly bool $translateUriToCamelCase; + private bool $translateURIDashes; + private bool $translateUriToCamelCase; /** * @param string $namespace the default namespace * @param list $httpMethods */ public function __construct( - private readonly string $namespace, - private readonly array $httpMethods, + private string $namespace, + private array $httpMethods, ) { $config = config(Routing::class); $this->translateURIDashes = $config->translateURIDashes; diff --git a/system/Commands/Utilities/Routes/ControllerFinder.php b/system/Commands/Utilities/Routes/ControllerFinder.php index 400e30e82d2e..103a7846ab52 100644 --- a/system/Commands/Utilities/Routes/ControllerFinder.php +++ b/system/Commands/Utilities/Routes/ControllerFinder.php @@ -20,15 +20,15 @@ * * @see \CodeIgniter\Commands\Utilities\Routes\ControllerFinderTest */ -final class ControllerFinder +final readonly class ControllerFinder { - private readonly FileLocatorInterface $locator; + private FileLocatorInterface $locator; /** * @param string $namespace namespace to search */ public function __construct( - private readonly string $namespace, + private string $namespace, ) { $this->locator = service('locator'); } diff --git a/system/Commands/Utilities/Routes/ControllerMethodReader.php b/system/Commands/Utilities/Routes/ControllerMethodReader.php index cd47085932bb..7c8faffd16be 100644 --- a/system/Commands/Utilities/Routes/ControllerMethodReader.php +++ b/system/Commands/Utilities/Routes/ControllerMethodReader.php @@ -21,12 +21,12 @@ * * @see \CodeIgniter\Commands\Utilities\Routes\ControllerMethodReaderTest */ -final class ControllerMethodReader +final readonly class ControllerMethodReader { /** * @param string $namespace the default namespace */ - public function __construct(private readonly string $namespace) + public function __construct(private string $namespace) { } diff --git a/system/Commands/Utilities/Routes/FilterCollector.php b/system/Commands/Utilities/Routes/FilterCollector.php index 052884a644b7..ec1219debe1a 100644 --- a/system/Commands/Utilities/Routes/FilterCollector.php +++ b/system/Commands/Utilities/Routes/FilterCollector.php @@ -24,7 +24,7 @@ * * @see \CodeIgniter\Commands\Utilities\Routes\FilterCollectorTest */ -final class FilterCollector +final readonly class FilterCollector { public function __construct( /** @@ -32,7 +32,7 @@ public function __construct( * * If set to true, route filters are not found. */ - private readonly bool $resetRoutes = false, + private bool $resetRoutes = false, ) { } diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php index f34ea26aa702..849a1cdb59c9 100644 --- a/system/Commands/Utilities/Routes/FilterFinder.php +++ b/system/Commands/Utilities/Routes/FilterFinder.php @@ -25,10 +25,10 @@ * * @see \CodeIgniter\Commands\Utilities\Routes\FilterFinderTest */ -final class FilterFinder +final readonly class FilterFinder { - private readonly Router $router; - private readonly Filters $filters; + private Router $router; + private Filters $filters; public function __construct(?Router $router = null, ?Filters $filters = null) { diff --git a/system/DataConverter/DataConverter.php b/system/DataConverter/DataConverter.php index fa8353e83abd..4895ec4127d2 100644 --- a/system/DataConverter/DataConverter.php +++ b/system/DataConverter/DataConverter.php @@ -24,12 +24,12 @@ * * @see \CodeIgniter\DataConverter\DataConverterTest */ -final class DataConverter +final readonly class DataConverter { /** * The data caster. */ - private readonly DataCaster $dataCaster; + private DataCaster $dataCaster; /** * @param array $castHandlers Custom convert handlers @@ -42,26 +42,26 @@ public function __construct( * * @var array [column => type] */ - private readonly array $types, + private array $types, array $castHandlers = [], /** * Helper object. */ - private readonly ?object $helper = null, + private ?object $helper = null, /** * Static reconstruct method name or closure to reconstruct an object. * Used by reconstruct(). * * @var (Closure(array): TEntity)|string|null */ - private readonly Closure|string|null $reconstructor = 'reconstruct', + private Closure|string|null $reconstructor = 'reconstruct', /** * Extract method name or closure to extract data from an object. * Used by extract(). * * @var (Closure(TEntity, bool, bool): array)|string|null */ - private readonly Closure|string|null $extractor = null, + private Closure|string|null $extractor = null, ) { $this->dataCaster = new DataCaster($castHandlers, $types, $this->helper); } diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 8e4834486261..3c43b173fc4e 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -17,8 +17,8 @@ * @template TConnection * @template TResult * - * @property false|object|resource $connID * @property-read string $DBDriver + * @property false|object|resource $connID */ interface ConnectionInterface { diff --git a/system/Database/Database.php b/system/Database/Database.php index 80e902d5ab58..3cc0cb8fa1b9 100644 --- a/system/Database/Database.php +++ b/system/Database/Database.php @@ -65,6 +65,8 @@ public function load(array $params = [], string $alias = '') /** * Creates a Forge instance for the current database type. + * + * @param BaseConnection $db */ public function loadForge(ConnectionInterface $db): Forge { @@ -77,6 +79,8 @@ public function loadForge(ConnectionInterface $db): Forge /** * Creates an instance of Utils for the current database type. + * + * @param BaseConnection $db */ public function loadUtils(ConnectionInterface $db): BaseUtils { diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 04c4429fd783..6bf914c785bb 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -84,17 +84,6 @@ public function __construct(ExceptionsConfig $config) $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; $this->config = $config; - - // workaround for upgraded users - // This causes "Deprecated: Creation of dynamic property" in PHP 8.2. - // @TODO remove this after dropping PHP 8.1 support. - if (! isset($this->config->sensitiveDataInTrace)) { - $this->config->sensitiveDataInTrace = []; - } - if (! isset($this->config->logDeprecations, $this->config->deprecationLogLevel)) { - $this->config->logDeprecations = false; - $this->config->deprecationLogLevel = LogLevel::WARNING; - } } /** diff --git a/system/HTTP/SiteURIFactory.php b/system/HTTP/SiteURIFactory.php index 483c3ded5779..73cc9fe84ee4 100644 --- a/system/HTTP/SiteURIFactory.php +++ b/system/HTTP/SiteURIFactory.php @@ -24,9 +24,9 @@ * * @see \CodeIgniter\HTTP\SiteURIFactoryTest */ -final class SiteURIFactory +final readonly class SiteURIFactory { - public function __construct(private readonly App $appConfig, private readonly Superglobals $superglobals) + public function __construct(private App $appConfig, private Superglobals $superglobals) { } diff --git a/system/Router/DefinedRouteCollector.php b/system/Router/DefinedRouteCollector.php index 0aa4fdd063d8..c66e25c03349 100644 --- a/system/Router/DefinedRouteCollector.php +++ b/system/Router/DefinedRouteCollector.php @@ -21,9 +21,9 @@ * * @see \CodeIgniter\Router\DefinedRouteCollectorTest */ -final class DefinedRouteCollector +final readonly class DefinedRouteCollector { - public function __construct(private readonly RouteCollectionInterface $routeCollection) + public function __construct(private RouteCollectionInterface $routeCollection) { } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 1070eb9380cd..7defca8f03e7 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -14,7 +14,7 @@ Release Date: Unreleased Highlights ********** -- TBD +- Update minimal PHP requirement to ``8.2``. ******** BREAKING diff --git a/user_guide_src/source/installation/installing_composer.rst b/user_guide_src/source/installation/installing_composer.rst index 3dd4c2a6a1b7..1f12d5321fef 100644 --- a/user_guide_src/source/installation/installing_composer.rst +++ b/user_guide_src/source/installation/installing_composer.rst @@ -191,11 +191,11 @@ Next Minor Version If you want to use the next minor version branch, after using the ``builds`` command edit **composer.json** manually. -If you try the ``4.6`` branch, change the version to ``4.6.x-dev``:: +If you try the ``4.7`` branch, change the version to ``4.7.x-dev``:: "require": { - "php": "^8.1", - "codeigniter4/codeigniter4": "4.6.x-dev" + "php": "^8.2", + "codeigniter4/codeigniter4": "4.7.x-dev" }, And run ``composer update`` to sync your vendor diff --git a/user_guide_src/source/intro/requirements.rst b/user_guide_src/source/intro/requirements.rst index c90cb9c8a7eb..4c956e631f0e 100644 --- a/user_guide_src/source/intro/requirements.rst +++ b/user_guide_src/source/intro/requirements.rst @@ -10,7 +10,7 @@ Server Requirements PHP and Required Extensions *************************** -`PHP `_ version 8.1 or newer is required, with the following PHP extensions are enabled: +`PHP `_ version 8.2 or newer is required, with the following PHP extensions are enabled: - `intl `_ - `mbstring `_ @@ -19,10 +19,12 @@ PHP and Required Extensions .. warning:: - The end of life date for PHP 7.4 was November 28, 2022. - The end of life date for PHP 8.0 was November 26, 2023. - - **If you are still using PHP 7.4 or 8.0, you should upgrade immediately.** - - The end of life date for PHP 8.1 will be December 31, 2025. + - The end of life date for PHP 8.1 was December 31, 2025. + - **If you are still using below PHP 8.2, you should upgrade immediately.** + - The end of life date for PHP 8.2 will be December 31, 2026. .. note:: + - PHP 8.5 requires CodeIgniter 4.7.0 or later. - PHP 8.4 requires CodeIgniter 4.6.0 or later. - PHP 8.3 requires CodeIgniter 4.4.4 or later. - PHP 8.2 requires CodeIgniter 4.2.11 or later. diff --git a/utils/composer.json b/utils/composer.json index 4c9ae87b3021..d8d707a48c1e 100644 --- a/utils/composer.json +++ b/utils/composer.json @@ -1,6 +1,6 @@ { "require": { - "php": "^8.1", + "php": "^8.2", "codeigniter/coding-standard": "^1.7", "ergebnis/composer-normalize": "^2.28", "friendsofphp/php-cs-fixer": "^3.76", diff --git a/utils/phpstan-baseline/booleanNot.exprNotBoolean.neon b/utils/phpstan-baseline/booleanNot.exprNotBoolean.neon new file mode 100644 index 000000000000..1a6537ef488d --- /dev/null +++ b/utils/phpstan-baseline/booleanNot.exprNotBoolean.neon @@ -0,0 +1,8 @@ +# total 2 errors + +parameters: + ignoreErrors: + - + message: '#^Only booleans are allowed in a negated boolean, mixed given\.$#' + count: 2 + path: ../../system/Database/Database.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index f54427206516..0a8697aa66d6 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,7 +1,8 @@ -# total 2816 errors +# total 2824 errors includes: - argument.type.neon - assign.propertyType.neon + - booleanNot.exprNotBoolean.neon - codeigniter.getReassignArray.neon - codeigniter.modelArgumentType.neon - codeigniter.superglobalAccess.neon diff --git a/utils/phpstan-baseline/property.notFound.neon b/utils/phpstan-baseline/property.notFound.neon index 282a9234fddd..10dac94f71dd 100644 --- a/utils/phpstan-baseline/property.notFound.neon +++ b/utils/phpstan-baseline/property.notFound.neon @@ -1,4 +1,4 @@ -# total 59 errors +# total 65 errors parameters: ignoreErrors: @@ -17,6 +17,21 @@ parameters: count: 14 path: ../../system/Database/SQLSRV/Forge.php + - + message: '#^Access to an undefined property CodeIgniter\\Debug\\ExceptionHandler\:\:\$stringAsHtml\.$#' + count: 1 + path: ../../system/Debug/ExceptionHandler.php + + - + message: '#^Access to an undefined property CodeIgniter\\Debug\\Exceptions\:\:\$stringAsHtml\.$#' + count: 1 + path: ../../system/Debug/Exceptions.php + + - + message: '#^Access to an undefined property CodeIgniter\\RESTful\\ResourceController\:\:\$stringAsHtml\.$#' + count: 1 + path: ../../system/RESTful/ResourceController.php + - message: '#^Access to an undefined property Config\\Session\:\:\$lockAttempts\.$#' count: 1 @@ -27,6 +42,21 @@ parameters: count: 1 path: ../../system/Session/Handlers/RedisHandler.php + - + message: '#^Access to an undefined property CodeIgniter\\Test\\Mock\\MockResourcePresenter\:\:\$stringAsHtml\.$#' + count: 1 + path: ../../system/Test/Mock/MockResourcePresenter.php + + - + message: '#^Access to an undefined property class@anonymous/tests/system/API/ResponseTraitTest\.php\:123\:\:\$stringAsHtml\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php + + - + message: '#^Access to an undefined property class@anonymous/tests/system/API/ResponseTraitTest\.php\:621\:\:\$stringAsHtml\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php + - message: '#^Access to an undefined property Tests\\Support\\Commands\\AppInfo\:\:\$foobar\.$#' count: 2 diff --git a/utils/src/Rector/UnderscoreToCamelCaseVariableNameRector.php b/utils/src/Rector/UnderscoreToCamelCaseVariableNameRector.php index 4471bdb7167c..4302dbd0b79e 100644 --- a/utils/src/Rector/UnderscoreToCamelCaseVariableNameRector.php +++ b/utils/src/Rector/UnderscoreToCamelCaseVariableNameRector.php @@ -93,7 +93,7 @@ public function refactor(Node $node): ?Node $this->traverseNodesWithCallable( $node->stmts, - function (Node $subNode) { + function (Node $subNode): null { if ($subNode instanceof Variable || $subNode instanceof ClassMethod || $subNode instanceof Function_ || $subNode instanceof Closure) { $this->processRenameVariable($subNode); } From f03ce8539897d40ae1db5610b3e45e50ef58fec8 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 5 Sep 2025 19:29:47 +0200 Subject: [PATCH 27/84] feat: signals (#9690) * feat: signals * docs: include cli_signals into the toctree * rename method areSignalsBlocked() to signalsBlocked() * update comments for methods * update return type for getProcessState() * apply code suggestions from code review * fix the tests * translate cli messages * fix typo in the language file * fix: psalm autoload * update changelog --- admin/framework/composer.json | 2 + composer.json | 2 + psalm-autoload.php | 11 +- system/CLI/SignalTrait.php | 400 ++++++++++++++++++ system/Commands/Database/Migrate.php | 11 +- system/Commands/Database/MigrateRefresh.php | 9 +- system/Commands/Database/MigrateRollback.php | 11 +- system/Language/en/CLI.php | 5 + tests/_support/Commands/SignalCommand.php | 121 ++++++ .../Commands/SignalCommandNoPcntl.php | 28 ++ .../Commands/SignalCommandNoPosix.php | 28 ++ tests/system/CLI/SignalTest.php | 229 ++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 3 +- user_guide_src/source/cli/cli_signals.rst | 341 +++++++++++++++ user_guide_src/source/cli/cli_signals/001.php | 25 ++ user_guide_src/source/cli/cli_signals/002.php | 52 +++ user_guide_src/source/cli/cli_signals/003.php | 51 +++ user_guide_src/source/cli/cli_signals/004.php | 44 ++ user_guide_src/source/cli/cli_signals/005.php | 58 +++ user_guide_src/source/cli/cli_signals/006.php | 34 ++ user_guide_src/source/cli/cli_signals/007.php | 16 + user_guide_src/source/cli/cli_signals/008.php | 14 + user_guide_src/source/cli/cli_signals/009.php | 21 + user_guide_src/source/cli/cli_signals/010.php | 11 + user_guide_src/source/cli/cli_signals/011.php | 32 ++ user_guide_src/source/cli/cli_signals/012.php | 22 + user_guide_src/source/cli/cli_signals/013.php | 29 ++ user_guide_src/source/cli/index.rst | 1 + 28 files changed, 1601 insertions(+), 10 deletions(-) create mode 100644 system/CLI/SignalTrait.php create mode 100644 tests/_support/Commands/SignalCommand.php create mode 100644 tests/_support/Commands/SignalCommandNoPcntl.php create mode 100644 tests/_support/Commands/SignalCommandNoPosix.php create mode 100644 tests/system/CLI/SignalTest.php create mode 100644 user_guide_src/source/cli/cli_signals.rst create mode 100644 user_guide_src/source/cli/cli_signals/001.php create mode 100644 user_guide_src/source/cli/cli_signals/002.php create mode 100644 user_guide_src/source/cli/cli_signals/003.php create mode 100644 user_guide_src/source/cli/cli_signals/004.php create mode 100644 user_guide_src/source/cli/cli_signals/005.php create mode 100644 user_guide_src/source/cli/cli_signals/006.php create mode 100644 user_guide_src/source/cli/cli_signals/007.php create mode 100644 user_guide_src/source/cli/cli_signals/008.php create mode 100644 user_guide_src/source/cli/cli_signals/009.php create mode 100644 user_guide_src/source/cli/cli_signals/010.php create mode 100644 user_guide_src/source/cli/cli_signals/011.php create mode 100644 user_guide_src/source/cli/cli_signals/012.php create mode 100644 user_guide_src/source/cli/cli_signals/013.php diff --git a/admin/framework/composer.json b/admin/framework/composer.json index aef544f51126..44b42109f824 100644 --- a/admin/framework/composer.json +++ b/admin/framework/composer.json @@ -38,7 +38,9 @@ "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", "ext-mysqli": "If you use MySQL", "ext-oci8": "If you use Oracle Database", + "ext-pcntl": "If you use Signals", "ext-pgsql": "If you use PostgreSQL", + "ext-posix": "If you use Signals", "ext-readline": "Improves CLI::input() usability", "ext-redis": "If you use Cache class RedisHandler", "ext-simplexml": "If you format XML", diff --git a/composer.json b/composer.json index a4692b49c9dc..e0b250aae310 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,9 @@ "ext-memcached": "If you use Cache class MemcachedHandler with Memcached", "ext-mysqli": "If you use MySQL", "ext-oci8": "If you use Oracle Database", + "ext-pcntl": "If you use Signals", "ext-pgsql": "If you use PostgreSQL", + "ext-posix": "If you use Signals", "ext-readline": "Improves CLI::input() usability", "ext-redis": "If you use Cache class RedisHandler", "ext-simplexml": "If you format XML", diff --git a/psalm-autoload.php b/psalm-autoload.php index 852ef4d6aab3..3eede20f1252 100644 --- a/psalm-autoload.php +++ b/psalm-autoload.php @@ -19,6 +19,8 @@ ]; foreach ($directories as $directory) { + $filesToLoad = []; + $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, @@ -45,6 +47,13 @@ continue; } - require_once $file->getPathname(); + $filesToLoad[] = $file->getPathname(); + } + + // Sort files to ensure consistent loading order across operating systems + sort($filesToLoad); + + foreach ($filesToLoad as $file) { + require_once $file; } } diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php new file mode 100644 index 000000000000..92eb2621df32 --- /dev/null +++ b/system/CLI/SignalTrait.php @@ -0,0 +1,400 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use Closure; + +/** + * Signal Trait + * + * Provides PCNTL signal handling capabilities for CLI commands. + * Requires the PCNTL extension (Unix only). + */ +trait SignalTrait +{ + /** + * Whether the process should continue running (false = termination requested). + */ + private bool $running = true; + + /** + * Whether signals are currently blocked. + */ + private bool $signalsBlocked = false; + + /** + * Array of registered signals. + * + * @var list + */ + private array $registeredSignals = []; + + /** + * Signal-to-method mapping. + * + * @var array + */ + private array $signalMethodMap = []; + + /** + * Cached result of PCNTL extension availability. + */ + private static ?bool $isPcntlAvailable = null; + + /** + * Cached result of POSIX extension availability. + */ + private static ?bool $isPosixAvailable = null; + + /** + * Check if PCNTL extension is available (cached). + */ + protected function isPcntlAvailable(): bool + { + if (self::$isPcntlAvailable === null) { + if (is_windows()) { + self::$isPcntlAvailable = false; + } else { + self::$isPcntlAvailable = extension_loaded('pcntl'); + if (! self::$isPcntlAvailable) { + CLI::write(lang('CLI.signals.noPcntlExtension'), 'yellow'); + } + } + } + + return self::$isPcntlAvailable; + } + + /** + * Check if POSIX extension is available (cached). + */ + protected function isPosixAvailable(): bool + { + if (self::$isPosixAvailable === null) { + self::$isPosixAvailable = is_windows() ? false : extension_loaded('posix'); + } + + return self::$isPosixAvailable; + } + + /** + * Register signal handlers. + * + * @param list $signals List of signals to handle + * @param array $methodMap Optional signal-to-method mapping + */ + protected function registerSignals( + array $signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT], + array $methodMap = [], + ): void { + if (! $this->isPcntlAvailable()) { + return; + } + + if (! $this->isPosixAvailable() && (in_array(SIGTSTP, $signals, true) || in_array(SIGCONT, $signals, true))) { + CLI::write(lang('CLI.signals.noPosixExtension'), 'yellow'); + $signals = array_diff($signals, [SIGTSTP, SIGCONT]); + + // Remove from method map as well + unset($methodMap[SIGTSTP], $methodMap[SIGCONT]); + + if ($signals === []) { + return; + } + } + + // Enable async signals for immediate response + pcntl_async_signals(true); + + $this->signalMethodMap = $methodMap; + + foreach ($signals as $signal) { + if (pcntl_signal($signal, [$this, 'handleSignal'])) { + $this->registeredSignals[] = $signal; + } else { + $signal = $this->getSignalName($signal); + CLI::write(lang('CLI.signals.failedSignal', [$signal]), 'red'); + } + } + } + + /** + * Handle incoming signals. + */ + protected function handleSignal(int $signal): void + { + $this->callCustomHandler($signal); + + // Apply standard Unix signal behavior for registered signals + switch ($signal) { + case SIGTERM: + case SIGINT: + case SIGQUIT: + case SIGHUP: + $this->running = false; + break; + + case SIGTSTP: + // Restore default handler and re-send signal to actually suspend + pcntl_signal(SIGTSTP, SIG_DFL); + posix_kill(posix_getpid(), SIGTSTP); + break; + + case SIGCONT: + // Re-register SIGTSTP handler after resume + pcntl_signal(SIGTSTP, [$this, 'handleSignal']); + break; + } + } + + /** + * Call custom signal handler if one is mapped for this signal. + * Falls back to generic onInterruption() method if no explicit mapping exists. + */ + private function callCustomHandler(int $signal): void + { + // Check for explicit mapping first + $method = $this->signalMethodMap[$signal] ?? null; + + if ($method !== null && method_exists($this, $method)) { + $this->{$method}($signal); + + return; + } + + // If no explicit mapping, try generic catch-all method + if (method_exists($this, 'onInterruption')) { + $this->onInterruption($signal); + } + } + + /** + * Check if command should terminate. + */ + protected function shouldTerminate(): bool + { + return ! $this->running; + } + + /** + * Check if the process is currently running (not terminated). + */ + protected function isRunning(): bool + { + return $this->running; + } + + /** + * Request immediate termination. + */ + protected function requestTermination(): void + { + $this->running = false; + } + + /** + * Reset all states (for testing or restart scenarios). + */ + protected function resetState(): void + { + $this->running = true; + + // Unblock signals if they were blocked + if ($this->signalsBlocked) { + $this->unblockSignals(); + } + } + + /** + * Execute a callable with ALL signals blocked to prevent ANY interruption during critical operations. + * + * This blocks ALL interruptible signals including: + * - Termination signals (SIGTERM, SIGINT, etc.) + * - Pause/resume signals (SIGTSTP, SIGCONT) + * - Custom signals (SIGUSR1, SIGUSR2) + * + * Only SIGKILL (unblockable) can still terminate the process. + * Use this for database transactions, file operations, or any critical atomic operations. + * + * @template TReturn + * + * @param Closure():TReturn $operation + * + * @return TReturn + */ + protected function withSignalsBlocked(Closure $operation) + { + $this->blockSignals(); + + try { + return $operation(); + } finally { + $this->unblockSignals(); + } + } + + /** + * Block ALL interruptible signals during critical sections. + * Only SIGKILL (unblockable) can terminate the process. + */ + protected function blockSignals(): void + { + if (! $this->signalsBlocked && $this->isPcntlAvailable()) { + // Block ALL signals that could interrupt critical operations + pcntl_sigprocmask(SIG_BLOCK, [ + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Termination signals + SIGTSTP, SIGCONT, // Pause/resume signals + SIGUSR1, SIGUSR2, // Custom signals + SIGPIPE, SIGALRM, // Other common signals + ]); + $this->signalsBlocked = true; + } + } + + /** + * Unblock previously blocked signals. + */ + protected function unblockSignals(): void + { + if ($this->signalsBlocked && $this->isPcntlAvailable()) { + // Unblock the same signals we blocked + pcntl_sigprocmask(SIG_UNBLOCK, [ + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Termination signals + SIGTSTP, SIGCONT, // Pause/resume signals + SIGUSR1, SIGUSR2, // Custom signals + SIGPIPE, SIGALRM, // Other common signals + ]); + $this->signalsBlocked = false; + } + } + + /** + * Check if signals are currently blocked. + */ + protected function signalsBlocked(): bool + { + return $this->signalsBlocked; + } + + /** + * Add or update signal-to-method mapping at runtime. + */ + protected function mapSignal(int $signal, string $method): void + { + $this->signalMethodMap[$signal] = $method; + } + + /** + * Get human-readable signal name. + */ + protected function getSignalName(int $signal): string + { + return match ($signal) { + SIGTERM => 'SIGTERM', + SIGINT => 'SIGINT', + SIGHUP => 'SIGHUP', + SIGQUIT => 'SIGQUIT', + SIGUSR1 => 'SIGUSR1', + SIGUSR2 => 'SIGUSR2', + SIGPIPE => 'SIGPIPE', + SIGALRM => 'SIGALRM', + SIGTSTP => 'SIGTSTP', + SIGCONT => 'SIGCONT', + default => "Signal {$signal}", + }; + } + + /** + * Unregister all signals (cleanup). + */ + protected function unregisterSignals(): void + { + if (! $this->isPcntlAvailable()) { + return; + } + + foreach ($this->registeredSignals as $signal) { + pcntl_signal($signal, SIG_DFL); + } + + $this->registeredSignals = []; + $this->signalMethodMap = []; + } + + /** + * Check if signals are registered. + */ + protected function hasSignals(): bool + { + return $this->registeredSignals !== []; + } + + /** + * Get list of registered signals. + * + * @return list + */ + protected function getSignals(): array + { + return $this->registeredSignals; + } + + /** + * Get comprehensive process state information. + * + * @return array{ + * pid: int, + * running: bool, + * pcntl_available: bool, + * registered_signals: int, + * registered_signals_names: array, + * signals_blocked: bool, + * explicit_mappings: int, + * memory_usage_mb: float, + * memory_peak_mb: float, + * session_id?: false|int, + * process_group?: false|int, + * has_controlling_terminal?: bool + * } + */ + protected function getProcessState(): array + { + $pid = getmypid(); + $state = [ + // Process identification + 'pid' => $pid, + 'running' => $this->running, + + // Signal handling status + 'pcntl_available' => $this->isPcntlAvailable(), + 'registered_signals' => count($this->registeredSignals), + 'registered_signals_names' => array_map([$this, 'getSignalName'], $this->registeredSignals), + 'signals_blocked' => $this->signalsBlocked, + 'explicit_mappings' => count($this->signalMethodMap), + + // System resources + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2), + ]; + + // Add terminal control info if POSIX extension is available + if ($this->isPosixAvailable()) { + $state['session_id'] = posix_getsid($pid); + $state['process_group'] = posix_getpgid($pid); + $state['has_controlling_terminal'] = posix_isatty(STDIN); + } + + return $state; + } +} diff --git a/system/Commands/Database/Migrate.php b/system/Commands/Database/Migrate.php index b422e4a6db73..9be1a894d4e1 100644 --- a/system/Commands/Database/Migrate.php +++ b/system/Commands/Database/Migrate.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\SignalTrait; use Throwable; /** @@ -22,6 +23,8 @@ */ class Migrate extends BaseCommand { + use SignalTrait; + /** * The group the command is lumped under * when listing commands. @@ -82,9 +85,11 @@ public function run(array $params) $runner->setNamespace($namespace); } - if (! $runner->latest($group)) { - CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore - } + $this->withSignalsBlocked(static function () use ($runner, $group): void { + if (! $runner->latest($group)) { + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + } + }); $messages = $runner->getCliMessages(); diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php index e5e8a6d967f9..b7863a001438 100644 --- a/system/Commands/Database/MigrateRefresh.php +++ b/system/Commands/Database/MigrateRefresh.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\SignalTrait; /** * Does a rollback followed by a latest to refresh the current state @@ -22,6 +23,8 @@ */ class MigrateRefresh extends BaseCommand { + use SignalTrait; + /** * The group the command is lumped under * when listing commands. @@ -83,7 +86,9 @@ public function run(array $params) // @codeCoverageIgnoreEnd } - $this->call('migrate:rollback', $params); - $this->call('migrate', $params); + $this->withSignalsBlocked(function () use ($params): void { + $this->call('migrate:rollback', $params); + $this->call('migrate', $params); + }); } } diff --git a/system/Commands/Database/MigrateRollback.php b/system/Commands/Database/MigrateRollback.php index cb6cbe3a7adb..5fa6ac171bfb 100644 --- a/system/Commands/Database/MigrateRollback.php +++ b/system/Commands/Database/MigrateRollback.php @@ -15,6 +15,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\SignalTrait; use CodeIgniter\Database\MigrationRunner; use Throwable; @@ -24,6 +25,8 @@ */ class MigrateRollback extends BaseCommand { + use SignalTrait; + /** * The group the command is lumped under * when listing commands. @@ -98,9 +101,11 @@ public function run(array $params) CLI::write(lang('Migrations.rollingBack') . ' ' . $batch, 'yellow'); - if (! $runner->regress($batch)) { - CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore - } + $this->withSignalsBlocked(static function () use ($runner, $batch): void { + if (! $runner->regress($batch)) { + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + } + }); $messages = $runner->getCliMessages(); diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index f636fb2131d4..247cd0158331 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -52,4 +52,9 @@ 'helpUsage' => 'Usage:', 'invalidColor' => 'Invalid "{0}" color: "{1}".', 'namespaceNotDefined' => 'Namespace "{0}" is not defined.', + 'signals' => [ + 'noPcntlExtension' => 'PCNTL extension not available. Signal handling disabled.', + 'noPosixExtension' => 'SIGTSTP/SIGCONT handling requires POSIX extension. These signals will be removed from registration.', + 'failedSignal' => 'Failed to register handler for signal: "{0}".', + ], ]; diff --git a/tests/_support/Commands/SignalCommand.php b/tests/_support/Commands/SignalCommand.php new file mode 100644 index 000000000000..219f76a48296 --- /dev/null +++ b/tests/_support/Commands/SignalCommand.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\SignalTrait; + +/** + * Mock command class that uses SignalTrait for testing + */ +class SignalCommand extends BaseCommand +{ + use SignalTrait; + + protected $name = 'test:signal'; + protected $description = 'Test signal handling'; + public bool $customHandlerCalled = false; + public bool $fallbackHandlerCalled = false; + public ?int $lastSignalReceived = null; + + public function run(array $params): int + { + return 0; + } + + // Test method to trigger custom handler + public function testCustomHandler(int $signal): void + { + $this->customHandlerCalled = true; + $this->lastSignalReceived = $signal; + } + + // Fallback handler for testing + public function onInterruption(int $signal): void + { + $this->fallbackHandlerCalled = true; + $this->lastSignalReceived = $signal; + } + + // Public test methods to access protected trait methods + public function testRegisterSignals(array $signals, array $methodMap = []): void + { + $this->registerSignals($signals, $methodMap); + } + + public function testCallSignalHandler(int $signal): void + { + $this->handleSignal($signal); + } + + public function testIsRunning(): bool + { + return $this->isRunning(); + } + + public function testShouldTerminate(): bool + { + return $this->shouldTerminate(); + } + + public function testRequestTermination(): void + { + $this->requestTermination(); + } + + public function testResetState(): void + { + $this->resetState(); + } + + public function testWithSignalsBlocked(callable $operation) + { + return $this->withSignalsBlocked($operation); + } + + public function testSignalsBlocked(): bool + { + return $this->signalsBlocked(); + } + + public function testMapSignal(int $signal, string $method): void + { + $this->mapSignal($signal, $method); + } + + public function testGetSignalName(int $signal): string + { + return $this->getSignalName($signal); + } + + public function testUnregisterSignals(): void + { + $this->unregisterSignals(); + } + + public function testHasSignals(): bool + { + return $this->hasSignals(); + } + + public function testGetSignals(): array + { + return $this->getSignals(); + } + + public function testGetProcessState(): array + { + return $this->getProcessState(); + } +} diff --git a/tests/_support/Commands/SignalCommandNoPcntl.php b/tests/_support/Commands/SignalCommandNoPcntl.php new file mode 100644 index 000000000000..2903c714afb3 --- /dev/null +++ b/tests/_support/Commands/SignalCommandNoPcntl.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands; + +/** + * Mock command that simulates missing PCNTL extension + */ +class SignalCommandNoPcntl extends SignalCommand +{ + /** + * Override to simulate PCNTL not being available + */ + protected function isPcntlAvailable(): bool + { + return false; + } +} diff --git a/tests/_support/Commands/SignalCommandNoPosix.php b/tests/_support/Commands/SignalCommandNoPosix.php new file mode 100644 index 000000000000..02ce3a1fcefd --- /dev/null +++ b/tests/_support/Commands/SignalCommandNoPosix.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands; + +/** + * Mock command that simulates missing POSIX extension + */ +class SignalCommandNoPosix extends SignalCommand +{ + /** + * Override to simulate POSIX not being available + */ + protected function isPosixAvailable(): bool + { + return false; + } +} diff --git a/tests/system/CLI/SignalTest.php b/tests/system/CLI/SignalTest.php new file mode 100644 index 000000000000..4c1044d1e8e3 --- /dev/null +++ b/tests/system/CLI/SignalTest.php @@ -0,0 +1,229 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Log\Logger; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Commands\SignalCommand; +use Tests\Support\Commands\SignalCommandNoPcntl; +use Tests\Support\Commands\SignalCommandNoPosix; + +/** + * @internal + */ +#[Group('Others')] +final class SignalTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private SignalCommand $command; + private Logger $logger; + + protected function setUp(): void + { + if (is_windows()) { + $this->markTestSkipped('Signal handling is not supported on Windows.'); + } + + if (! extension_loaded('pcntl')) { + $this->markTestSkipped('PCNTL extension is required for signal handling tests.'); + } + + if (! extension_loaded('posix')) { + $this->markTestSkipped('POSIX extension is required for signal handling tests.'); + } + + $this->resetServices(); + parent::setUp(); + + $this->logger = service('logger'); + $this->command = new SignalCommand($this->logger, service('commands')); + } + + public function testSignalRegistration(): void + { + $this->command->testRegisterSignals([SIGTERM, SIGINT], [SIGTERM => 'customTermHandler']); + + $this->assertTrue($this->command->testHasSignals()); + $this->assertSame([SIGTERM, SIGINT], $this->command->testGetSignals()); + + $state = $this->command->testGetProcessState(); + $this->assertSame(2, $state['registered_signals']); + $this->assertSame(['SIGTERM', 'SIGINT'], $state['registered_signals_names']); + $this->assertSame(1, $state['explicit_mappings']); + } + + public function testSignalRegistrationWithoutPcntl(): void + { + $command = new SignalCommandNoPcntl($this->logger, service('commands')); + + $command->testRegisterSignals([SIGTERM, SIGINT]); + + $this->assertFalse($command->testHasSignals()); + $this->assertSame([], $command->testGetSignals()); + } + + public function testSignalRegistrationFiltersPosixDependentSignals(): void + { + $this->resetStreamFilterBuffer(); + + $commandNoPosix = new SignalCommandNoPosix($this->logger, service('commands')); + + $commandNoPosix->testRegisterSignals([SIGTERM, SIGTSTP, SIGCONT], [SIGTSTP => 'onPause']); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString( + 'SIGTSTP/SIGCONT handling requires POSIX extension', + $output, + ); + + $this->assertSame([SIGTERM], $commandNoPosix->testGetSignals()); + } + + public function testProcessState(): void + { + $this->command->testRegisterSignals([SIGTERM, SIGINT, SIGUSR1]); + + $state = $this->command->testGetProcessState(); + + // Process identification + $this->assertArrayHasKey('pid', $state); + $this->assertIsInt($state['pid']); + $this->assertTrue($state['running']); + + // Signal handling status + $this->assertTrue($state['pcntl_available']); + $this->assertSame(3, $state['registered_signals']); + $this->assertSame(['SIGTERM', 'SIGINT', 'SIGUSR1'], $state['registered_signals_names']); + $this->assertFalse($state['signals_blocked']); + $this->assertSame(0, $state['explicit_mappings']); + + // System resources + $this->assertArrayHasKey('memory_usage_mb', $state); + $this->assertArrayHasKey('memory_peak_mb', $state); + $this->assertIsFloat($state['memory_usage_mb']); + $this->assertIsFloat($state['memory_peak_mb']); + } + + public function testProcessStateIncludesPosixInfo(): void + { + $state = $this->command->testGetProcessState(); + + $this->assertArrayHasKey('session_id', $state); + $this->assertArrayHasKey('process_group', $state); + $this->assertArrayHasKey('has_controlling_terminal', $state); + + $this->assertIsInt($state['session_id']); + $this->assertIsInt($state['process_group']); + $this->assertIsBool($state['has_controlling_terminal']); + } + + public function testRunningState(): void + { + $this->assertTrue($this->command->testIsRunning()); + $this->assertFalse($this->command->testShouldTerminate()); + + $this->command->testRequestTermination(); + + $this->assertFalse($this->command->testIsRunning()); + $this->assertTrue($this->command->testShouldTerminate()); + } + + public function testSignalBlocking(): void + { + $this->assertFalse($this->command->testSignalsBlocked()); + + $result = $this->command->testWithSignalsBlocked(function (): string { + $this->assertTrue($this->command->testSignalsBlocked()); + + return 'test_result'; + }); + + $this->assertSame('test_result', $result); + $this->assertFalse($this->command->testSignalsBlocked()); + } + + public function testSignalMethodMapping(): void + { + $this->command->testMapSignal(SIGUSR1, 'customHandler'); + + $state = $this->command->testGetProcessState(); + $this->assertSame(1, $state['explicit_mappings']); + } + + public function testResetState(): void + { + $this->command->testRequestTermination(); + $this->command->testWithSignalsBlocked(static fn (): bool => true); + + $this->assertTrue($this->command->testShouldTerminate()); + + $this->command->testResetState(); + + $this->assertFalse($this->command->testShouldTerminate()); + $this->assertFalse($this->command->testSignalsBlocked()); + } + + public function testSignalNameGeneration(): void + { + $this->assertSame('SIGTERM', $this->command->testGetSignalName(SIGTERM)); + $this->assertSame('SIGINT', $this->command->testGetSignalName(SIGINT)); + $this->assertSame('SIGUSR1', $this->command->testGetSignalName(SIGUSR1)); + $this->assertSame('Signal 999', $this->command->testGetSignalName(999)); + } + + public function testUnregisterSignals(): void + { + $this->command->testRegisterSignals([SIGTERM, SIGINT]); + $this->assertTrue($this->command->testHasSignals()); + + $this->command->testUnregisterSignals(); + $this->assertFalse($this->command->testHasSignals()); + $this->assertSame([], $this->command->testGetSignals()); + } + + public function testCustomSignalHandlerCall(): void + { + $this->command->testRegisterSignals([SIGTERM], [SIGTERM => 'testCustomHandler']); + + $this->command->testCallSignalHandler(SIGTERM); + + $this->assertTrue($this->command->customHandlerCalled); + $this->assertSame(SIGTERM, $this->command->lastSignalReceived); + } + + public function testFallbackSignalHandlerCall(): void + { + $this->command->testRegisterSignals([SIGINT]); // No explicit mapping + + $this->command->testCallSignalHandler(SIGINT); + + $this->assertTrue($this->command->fallbackHandlerCalled); + $this->assertSame(SIGINT, $this->command->lastSignalReceived); + } + + public function testSignalHandlerUpdatesRunningState(): void + { + $this->command->testRegisterSignals([SIGTERM]); + + $this->assertTrue($this->command->testIsRunning()); + + $this->command->testCallSignalHandler(SIGTERM); + + $this->assertFalse($this->command->testIsRunning()); + $this->assertTrue($this->command->testShouldTerminate()); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 7defca8f03e7..78eecb7fe7f5 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -53,6 +53,7 @@ Enhancements Libraries ========= +- **CLI:** Added ``SignalTrait`` to provide unified handling of operating system signals in CLI commands. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disabled request fresh connection. @@ -99,7 +100,7 @@ Others Message Changes *************** -- Added ``Email.invalidSMTPAuthMethod`` and ``Email.failureSMTPAuthMethod`` +- Added ``Email.invalidSMTPAuthMethod``, ``Email.failureSMTPAuthMethod``, ``CLI.signals.noPcntlExtension``, ``CLI.signals.noPosixExtension`` and ``CLI.signals.failedSignal``. - Deprecated ``Email.failedSMTPLogin`` and ``Image.libPathInvalid`` ******* diff --git a/user_guide_src/source/cli/cli_signals.rst b/user_guide_src/source/cli/cli_signals.rst new file mode 100644 index 000000000000..b5f74a792f35 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals.rst @@ -0,0 +1,341 @@ +########### +CLI Signals +########### + +Unix signals are a fundamental part of process communication and control. They provide a way to interrupt, control, +and communicate with running processes. CodeIgniter's SignalTrait makes it easy to handle signals in your CLI commands, +enabling graceful shutdowns, pause/resume functionality, and custom signal handling. + +.. contents:: + :local: + :depth: 2 + +***************** +What are Signals? +***************** + +Signals are software interrupts delivered to a process by the operating system. They notify processes of various +events, from user-initiated interruptions (like pressing Ctrl+C) to system events (like terminal disconnection). + +``SignalTrait`` adds the ability to perform certain actions before the signal is consumed, as well as the capability +to protect certain pieces of code from signal interruption. This protection mechanism guarantees the proper execution +of critical command operations by ensuring they complete atomically without being interrupted by incoming signals. + +Common Unix Signals +=================== + +Here are the most commonly used signals in CLI applications: + +**Handleable Signals:** + +* **SIGTERM (15)**: Termination signal - requests graceful shutdown +* **SIGINT (2)**: Interrupt signal - typically sent by Ctrl+C +* **SIGHUP (1)**: Hangup signal - terminal disconnected or closed +* **SIGQUIT (3)**: Quit signal - typically sent by Ctrl+\\ +* **SIGTSTP (20)**: Terminal stop - typically sent by Ctrl+Z (suspend) +* **SIGCONT (18)**: Continue signal - resume suspended process (fg command) +* **SIGUSR1 (10)**: User-defined signal 1 +* **SIGUSR2 (12)**: User-defined signal 2 + +**Unhandleable Signals:** + +Some signals cannot be caught, blocked, or handled by user processes: + +* **SIGKILL (9)**: Forceful termination - cannot be caught or ignored +* **SIGSTOP (19)**: Forceful suspend - cannot be caught or ignored + +These signals are handled directly by the kernel and will terminate or suspend your process immediately, bypassing any custom handlers. + +System Requirements +=================== + +Signal handling requires: + +* **Unix-based system** (Linux, macOS, BSD) - Windows is not supported +* **PCNTL extension** - for signal registration and handling +* **POSIX extension** - required for pause/resume functionality (SIGTSTP/SIGCONT) + +.. note:: On systems without these extensions, the SignalTrait will gracefully degrade and disable signal handling. + +********************* +Using the SignalTrait +********************* + +The ``SignalTrait`` provides a comprehensive signal handling system for CLI commands. To use it, simply add the trait +to your command class and register signals in your command's ``run()`` method: + +.. literalinclude:: cli_signals/001.php + +This registers three termination signals that will set the ``$running`` state to ``false`` when received. + +Custom Signal Handlers +====================== + +You can map signals to custom methods for specific behavior: + +.. literalinclude:: cli_signals/002.php + +Fallback Signal Handler +======================= + +For signals without explicit mappings, you can implement a generic ``onInterruption()`` method: + +.. literalinclude:: cli_signals/003.php + +***************** +Critical Sections +***************** + +Some operations should never be interrupted (database transactions, file operations). Use ``withSignalsBlocked()`` +to create atomic operations: + +.. literalinclude:: cli_signals/004.php + +During critical sections, ALL signals (including Ctrl+Z) are blocked to prevent data corruption. + +**************** +Pause and Resume +**************** + +The SignalTrait supports proper Unix job control with custom handlers: + +.. literalinclude:: cli_signals/005.php + +How Pause/Resume Works +====================== + +1. **SIGTSTP received**: Custom ``onPause()`` handler runs +2. **Process suspends**: Using standard Unix job control +3. **SIGCONT received**: Process resumes, then ``onResume()`` handler runs + +This allows you to save state before suspension and restore it after resumption while maintaining proper shell integration. + +Important Limitations +===================== + +**Shell Job Control vs Manual Signals** + +There's a critical difference between using shell job control and manually sending signals: + +.. code-block:: bash + + # RECOMMENDED: Use shell job control + php spark my:command + # Press Ctrl+Z to suspend + fg # Resume - maintains terminal control + + # PROBLEMATIC: Manual signal sending + php spark my:command & + kill -TSTP $PID # Suspend + kill -CONT $PID # Resume - may lose terminal control + +**The Problem with Manual SIGCONT** + +When you manually send ``kill -CONT`` from a different terminal: + +**Expected behavior:** + - Process resumes and custom handlers execute + +**Side effects:** + - Process loses foreground terminal control + - Ctrl+C and Ctrl+Z may stop working + - Process runs in background state + +This happens because manual ``kill -CONT`` doesn't restore the process to the terminal's foreground process group. + +**Best Practices for Pause/Resume** + +1. **Use shell job control** (Ctrl+Z, fg, bg) when possible +2. **Document the limitation** if your application needs manual signal control +3. **Provide alternative control methods** for automated environments +4. **Test thoroughly** in your deployment environment + +****************** +Triggering Signals +****************** + +From Command Line +================= + +You can send signals to running processes using the ``kill`` command: + +.. code-block:: bash + + # Get the process ID + php spark long:running:command & + echo $! # Shows PID, e.g., 12345 + + # Send different signals + kill -TERM 12345 # Graceful shutdown + kill -INT 12345 # Interrupt (same as Ctrl+C) + kill -HUP 12345 # Hangup + kill -USR1 12345 # User-defined signal 1 + kill -USR2 12345 # User-defined signal 2 + + # Pause and resume + kill -TSTP 12345 # Suspend (same as Ctrl+Z) + kill -CONT 12345 # Resume (same as fg) + +Keyboard Shortcuts +================== + +These keyboard shortcuts send signals to the foreground process: + +* **Ctrl+C**: Sends SIGINT (interrupt) +* **Ctrl+Z**: Sends SIGTSTP (suspend/pause) +* **Ctrl+\\**: Sends SIGQUIT (quit with core dump) + +Job Control +=========== + +Standard Unix job control works seamlessly: + +.. code-block:: bash + + php spark long:command # Run in foreground + # Press Ctrl+Z to suspend + bg # Move to background + fg # Bring back to foreground + jobs # List suspended jobs + +***************** +Debugging Signals +***************** + +Process State Information +========================= + +Use ``getProcessState()`` to debug signal issues: + +.. literalinclude:: cli_signals/006.php + +This returns comprehensive information including: + +* Process ID and running state +* Registered signals and mappings +* Memory usage statistics +* Terminal control information (session, process group) +* Signal blocking status + +*************** +Class Reference +*************** + +.. php:namespace:: CodeIgniter\CLI + +.. php:trait:: SignalTrait + + .. php:method:: registerSignals($signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT], $methodMap = []) + + :param array $signals: List of signals to handle + :param array $methodMap: Optional signal-to-method mapping + :rtype: void + + Register signal handlers with optional custom method mapping. + + .. literalinclude:: cli_signals/007.php + + .. note:: Requires the PCNTL extension. On Windows, signal handling is automatically disabled. + + .. php:method:: isRunning() + + :returns: true if the process should continue running, false if not + :rtype: bool + + Check if the process should continue running (not terminated). + + .. literalinclude:: cli_signals/008.php + + .. php:method:: shouldTerminate() + + :returns: true if termination has been requested, false if not + :rtype: bool + + Check if termination has been requested (opposite of ``isRunning()``). + + .. literalinclude:: cli_signals/009.php + + .. php:method:: requestTermination() + + :rtype: void + + Manually request process termination. + + .. literalinclude:: cli_signals/010.php + + .. php:method:: resetState() + + :rtype: void + + Reset all states - useful for testing or restart scenarios. + + .. php:method:: withSignalsBlocked($operation) + + :param callable $operation: The critical operation to execute without interruption + :returns: The result of the operation + :rtype: mixed + + Execute a critical operation with ALL signals blocked to prevent ANY interruption. + + .. note:: This blocks ALL interruptible signals including termination signals (SIGTERM, SIGINT), + pause/resume signals (SIGTSTP, SIGCONT), and custom signals (SIGUSR1, SIGUSR2). + Only SIGKILL (unblockable) can still terminate the process. + + .. php:method:: areSignalsBlocked() + + :returns: true if signals are currently blocked, false if not + :rtype: bool + + Check if signals are currently blocked. + + .. php:method:: mapSignal($signal, $method) + + :param int $signal: Signal constant + :param string $method: Method name to call for this signal + :rtype: void + + Add or update signal-to-method mapping at runtime. + + .. literalinclude:: cli_signals/011.php + + .. php:method:: getSignalName($signal) + + :param int $signal: Signal constant + :returns: Human-readable signal name + :rtype: string + + Get human-readable name for a signal constant. + + .. literalinclude:: cli_signals/012.php + + .. php:method:: hasSignals() + + :returns: true if any signals are registered, false if not + :rtype: bool + + Check if any signals are registered. + + .. php:method:: getSignals() + + :returns: Array of registered signal constants + :rtype: array + + Get array of registered signal constants. + + .. php:method:: getProcessState() + + :returns: Comprehensive process state information + :rtype: array + + Get comprehensive process state information including process ID, memory usage, + signal handling status, and terminal control information. + + .. literalinclude:: cli_signals/013.php + + .. php:method:: unregisterSignals() + + :rtype: void + + Unregister all signals and clean up resources. + + .. note:: This removes all signal handling behavior for all previously registered signals. diff --git a/user_guide_src/source/cli/cli_signals/001.php b/user_guide_src/source/cli/cli_signals/001.php new file mode 100644 index 000000000000..1082802003ea --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/001.php @@ -0,0 +1,25 @@ +registerSignals(); + + // Main processing loop + while ($this->isRunning()) { + // Do work here + $this->processItem(); + + sleep(3); + } + + CLI::write('Command terminated gracefully', 'green'); + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_signals/002.php b/user_guide_src/source/cli/cli_signals/002.php new file mode 100644 index 000000000000..e75c7af56ae6 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/002.php @@ -0,0 +1,52 @@ +registerSignals( + [SIGTERM, SIGINT, SIGUSR1, SIGUSR2], + [ + SIGTERM => 'onGracefulShutdown', + SIGINT => 'onInterrupt', + SIGUSR1 => 'onToggleDebug', + SIGUSR2 => 'onStatusReport', + ], + ); + + while ($this->isRunning()) { + // Call custom method + $this->doWork(); + sleep(1); + } + + return EXIT_SUCCESS; + } + + protected function onGracefulShutdown(int $signal): void + { + CLI::write('Received SIGTERM - shutting down gracefully...', 'yellow'); + } + + protected function onInterrupt(int $signal): void + { + CLI::write('Received SIGINT - stopping!', 'red'); + } + + protected function onToggleDebug(int $signal): void + { + // Custom debug mode + $this->debugMode = ! $this->debugMode; + CLI::write('Debug mode: ' . ($this->debugMode ? 'ON' : 'OFF'), 'blue'); + } + + protected function onStatusReport(int $signal): void + { + $state = $this->getProcessState(); + CLI::write('Status: ' . json_encode($state, JSON_PRETTY_PRINT), 'cyan'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/003.php b/user_guide_src/source/cli/cli_signals/003.php new file mode 100644 index 000000000000..ba95e7a91d8e --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/003.php @@ -0,0 +1,51 @@ +registerSignals([SIGTERM, SIGINT, SIGHUP, SIGUSR1]); + + while ($this->isRunning()) { + $this->doWork(); + sleep(1); + } + + return EXIT_SUCCESS; + } + + /** + * Generic handler for all unmapped signals + */ + protected function onInterruption(int $signal): void + { + $signalName = $this->getSignalName($signal); + CLI::write("Received {$signalName} - handling generically", 'yellow'); + + switch ($signal) { + case SIGTERM: + CLI::write('Graceful shutdown requested', 'green'); + break; + + case SIGINT: + CLI::write('Immediate shutdown requested', 'red'); + break; + + case SIGHUP: + CLI::write('Configuration reload requested', 'blue'); + break; + + case SIGUSR1: + CLI::write('User signal 1 received', 'cyan'); + break; + + default: + CLI::write('Unknown signal received', 'light_gray'); + break; + } + } +} diff --git a/user_guide_src/source/cli/cli_signals/004.php b/user_guide_src/source/cli/cli_signals/004.php new file mode 100644 index 000000000000..83e6bbede0e2 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/004.php @@ -0,0 +1,44 @@ +withSignalsBlocked(function () use ($orderData) { + CLI::write('Starting critical transaction - signals blocked', 'yellow'); + + // Start database transaction + $this->db->transStart(); + + try { + // Create order record + $orderId = $this->createOrder($orderData); + + // Update inventory + $this->updateInventory($orderData['items']); + + // Process payment + $this->processPayment($orderId, $orderData['payment']); + + // Commit transaction + $this->db->transCommit(); + + CLI::write('Transaction completed successfully', 'green'); + + return $orderId; + } catch (\Exception $e) { + // Rollback on error + $this->db->transRollback(); + + throw $e; + } + }); + + CLI::write('Critical section complete - signals restored', 'cyan'); + CLI::write("Order {$result} processed successfully", 'green'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/005.php b/user_guide_src/source/cli/cli_signals/005.php new file mode 100644 index 000000000000..883949df4a0e --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/005.php @@ -0,0 +1,58 @@ +registerSignals( + [SIGTERM, SIGINT, SIGTSTP, SIGCONT], + [ + SIGTSTP => 'onPause', + SIGCONT => 'onResume', + ], + ); + + while ($this->isRunning()) { + $this->processWork(); + sleep(2); + } + + return EXIT_SUCCESS; + } + + protected function onPause(int $signal): void + { + CLI::write('Pausing - saving current date...', 'yellow'); + + // Save current timestamp + $state = [ + 'timestamp' => Time::now()->getTimestamp(), + ]; + + file_put_contents(WRITEPATH . 'app_state.json', json_encode($state)); + + CLI::write('State saved. Process will now suspend.', 'green'); + } + + protected function onResume(int $signal): void + { + CLI::write('Resuming - restoring...', 'green'); + + $file = WRITEPATH . 'app_state.json'; + + // Restore saved state + if (file_exists($file)) { + $state = json_decode(file_get_contents($file), true); + $date = Time::createFromTimestamp($state['timestamp'])->format('Y-m-d H:i:s'); + + CLI::write('Restored from ' . $date, 'cyan'); + } + + CLI::write('Resuming normal operation...', 'green'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/006.php b/user_guide_src/source/cli/cli_signals/006.php new file mode 100644 index 000000000000..9db6ee990cd8 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/006.php @@ -0,0 +1,34 @@ +getProcessState(); + + CLI::write('=== PROCESS DEBUG INFO ===', 'yellow'); + CLI::write('PID: ' . $state['pid'], 'cyan'); + CLI::write('Running: ' . ($state['running'] ? 'YES' : 'NO'), 'cyan'); + CLI::write('PCNTL Available: ' . ($state['pcntl_available'] ? 'YES' : 'NO'), 'cyan'); + CLI::write('Signals Registered: ' . $state['registered_signals'], 'cyan'); + CLI::write('Signal Names: ' . implode(', ', $state['registered_signals_names']), 'cyan'); + CLI::write('Explicit Mappings: ' . $state['explicit_mappings'], 'cyan'); + CLI::write('Signals Blocked: ' . ($state['signals_blocked'] ? 'YES' : 'NO'), 'cyan'); + CLI::write('Memory Usage: ' . $state['memory_usage_mb'] . ' MB', 'cyan'); + CLI::write('Peak Memory: ' . $state['memory_peak_mb'] . ' MB', 'cyan'); + + // POSIX info (if available) + if (isset($state['session_id'])) { + CLI::write('Session ID: ' . $state['session_id'], 'cyan'); + CLI::write('Process Group: ' . $state['process_group'], 'cyan'); + CLI::write('Has Terminal: ' . ($state['has_controlling_terminal'] ? 'YES' : 'NO'), 'cyan'); + } + + CLI::write('========================', 'yellow'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/007.php b/user_guide_src/source/cli/cli_signals/007.php new file mode 100644 index 000000000000..bf0ad2fe959e --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/007.php @@ -0,0 +1,16 @@ +registerSignals(); + +// Register specific signals +$this->registerSignals([SIGTERM, SIGINT]); + +// Register signals with custom method mapping +$this->registerSignals( + [SIGTERM, SIGINT, SIGUSR1], + [ + SIGTERM => 'handleGracefulShutdown', + SIGUSR1 => 'handleReload', + ], +); diff --git a/user_guide_src/source/cli/cli_signals/008.php b/user_guide_src/source/cli/cli_signals/008.php new file mode 100644 index 000000000000..4781a42a8d55 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/008.php @@ -0,0 +1,14 @@ +isRunning()) { + // Process work items + $this->processNextItem(); + + // Small delay to prevent CPU spinning + usleep(100000); // 0.1 seconds +} + +CLI::write('Process terminated gracefully.'); diff --git a/user_guide_src/source/cli/cli_signals/009.php b/user_guide_src/source/cli/cli_signals/009.php new file mode 100644 index 000000000000..c697dd5ad1c0 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/009.php @@ -0,0 +1,21 @@ +shouldTerminate()) { + CLI::write('Termination requested, skipping file processing.'); + + return; +} + +// Process large file +foreach ($largeDataSet as $item) { + // Check periodically during long operations + if ($this->shouldTerminate()) { + CLI::write('Termination requested during processing.'); + break; + } + + $this->processItem($item); +} diff --git a/user_guide_src/source/cli/cli_signals/010.php b/user_guide_src/source/cli/cli_signals/010.php new file mode 100644 index 000000000000..57ebc3812f58 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/010.php @@ -0,0 +1,11 @@ + $this->maxErrors) { + CLI::write("Too many errors ({$errorCount}), requesting termination.", 'red'); + $this->requestTermination(); + + return; +} diff --git a/user_guide_src/source/cli/cli_signals/011.php b/user_guide_src/source/cli/cli_signals/011.php new file mode 100644 index 000000000000..480c2d97364f --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/011.php @@ -0,0 +1,32 @@ +registerSignals([SIGTERM, SIGINT, SIGUSR1, SIGUSR2]); + + // Map signals to specific methods at runtime + $this->mapSignal(SIGUSR1, 'handleReload'); + $this->mapSignal(SIGUSR2, 'handleStatusDump'); + } + + // Custom signal handlers + public function handleReload(int $signal): void + { + CLI::write('Received reload signal, reloading configuration...'); + $this->reloadConfig(); + } + + public function handleStatusDump(int $signal): void + { + CLI::write('=== Process Status ==='); + $this->printStatus($this->getProcessState()); + } +} diff --git a/user_guide_src/source/cli/cli_signals/012.php b/user_guide_src/source/cli/cli_signals/012.php new file mode 100644 index 000000000000..c669e7385633 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/012.php @@ -0,0 +1,22 @@ +getSignalName($signal); + CLI::write("Received signal: {$signalName} ({$signal})", 'yellow'); + } +} diff --git a/user_guide_src/source/cli/cli_signals/013.php b/user_guide_src/source/cli/cli_signals/013.php new file mode 100644 index 000000000000..16131a914d60 --- /dev/null +++ b/user_guide_src/source/cli/cli_signals/013.php @@ -0,0 +1,29 @@ +getProcessState(); + + CLI::write('=== Process Debug Information ==='); + CLI::write("PID: {$state['pid']}"); + CLI::write('Running: ' . ($state['running'] ? 'Yes' : 'No')); + CLI::write("Memory Usage: {$state['memory_usage_mb']} MB"); + CLI::write("Peak Memory: {$state['memory_peak_mb']} MB"); + CLI::write('Registered Signals: ' . implode(', ', $state['registered_signals_names'])); + CLI::write('Signals Blocked: ' . ($state['signals_blocked'] ? 'Yes' : 'No')); + } +} diff --git a/user_guide_src/source/cli/index.rst b/user_guide_src/source/cli/index.rst index 01fa70847b0a..cbbb572243e6 100644 --- a/user_guide_src/source/cli/index.rst +++ b/user_guide_src/source/cli/index.rst @@ -13,4 +13,5 @@ CodeIgniter 4 can also be used with command line programs. cli_commands cli_generators cli_library + cli_signals cli_request From e39c749b532b65c62c3ef4cc5b1c6d738ba38e2f Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 5 Sep 2025 19:30:45 +0200 Subject: [PATCH 28/84] feat: make `insertBatch()` and `updateBatch()` respect model rules (#9708) * feat: make insertBatch() and updateBatch() respect model rules * update phpstan baseline --- system/BaseModel.php | 55 ++----------------- tests/system/Models/UpdateModelTest.php | 4 +- user_guide_src/source/changelogs/v4.7.0.rst | 12 +++- .../missingType.property.neon | 16 +++--- 4 files changed, 27 insertions(+), 60 deletions(-) diff --git a/system/BaseModel.php b/system/BaseModel.php index a1859a11b450..d3b3cd56bbdd 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -208,7 +208,8 @@ abstract class BaseModel protected bool $updateOnlyChanged = true; /** - * Rules used to validate data in insert(), update(), and save() methods. + * Rules used to validate data in insert(), update(), save(), + * insertBatch(), and updateBatch() methods. * * The array must match the format of data passed to the Validation * library. @@ -909,7 +910,7 @@ public function insertBatch(?array $set = null, ?bool $escape = null, int $batch if (is_array($set)) { foreach ($set as &$row) { - $row = $this->transformDataRowToArray($row); + $row = $this->transformDataToArray($row, 'insert'); // Validate every row. if (! $this->skipValidation && ! $this->validate($row)) { @@ -1036,7 +1037,7 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc { if (is_array($set)) { foreach ($set as &$row) { - $row = $this->transformDataRowToArray($row); + $row = $this->transformDataToArray($row, 'update'); // Validate data before saving. if (! $this->skipValidation && ! $this->validate($row)) { @@ -1667,52 +1668,6 @@ protected function trigger(string $event, array $eventData) return $eventData; } - /** - * If the model is using casts, this will convert the data - * in $row according to the rules defined in `$casts`. - * - * @param object|row_array|null $row Row data - * - * @return object|row_array|null Converted row data - * - * @used-by insertBatch() - * @used-by updateBatch() - * - * @throws ReflectionException - * @deprecated Since 4.6.4, temporary solution - will be removed in 4.7 - */ - protected function transformDataRowToArray(array|object|null $row): array|object|null - { - // If casts are used, convert the data first - if ($this->useCasts()) { - if (is_array($row)) { - $row = $this->converter->toDataSource($row); - } elseif ($row instanceof stdClass) { - $row = (array) $row; - $row = $this->converter->toDataSource($row); - } elseif ($row instanceof Entity) { - $row = $this->converter->extract($row); - } elseif (is_object($row)) { - $row = $this->converter->extract($row); - } - } elseif (is_object($row) && ! $row instanceof stdClass) { - // If $row is using a custom class with public or protected - // properties representing the collection elements, we need to grab - // them as an array. - $row = $this->objectToArray($row, false, true); - } - - // If it's still a stdClass, go ahead and convert to - // an array so doProtectFields and other model methods - // don't have to do special checks. - if (is_object($row)) { - $row = (array) $row; - } - - // Convert any Time instances to appropriate $dateFormat - return $this->timeToString($row); - } - /** * Sets the return type of the results to be as an associative array. * @@ -1830,7 +1785,9 @@ protected function objectToRawArray($object, bool $onlyChanged = true, bool $rec * @throws ReflectionException * * @used-by insert() + * @used-by insertBatch() * @used-by update() + * @used-by updateBatch() */ protected function transformDataToArray($row, string $type): array { diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index 2f01bb6a13f8..802896369aa5 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -249,7 +249,9 @@ public function testUpdateBatchWithEntity(): void $entity2->deleted = 0; $entity2->syncOriginal(); - $this->assertSame(2, $this->createModel(UserModel::class)->updateBatch([$entity1, $entity2], 'id')); + $model = $this->createModel(UserModel::class); + $this->setPrivateProperty($model, 'updateOnlyChanged', false); + $this->assertSame(2, $model->updateBatch([$entity1, $entity2], 'id')); } public function testUpdateNoPrimaryKey(): void diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 78eecb7fe7f5..a9c888592555 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -33,6 +33,13 @@ update it to use double braces: ``regex_match[/^{{placeholder}}$/]``. This change was introduced to avoid ambiguity with regular expression syntax, where single curly braces (e.g., ``{1,3}``) are used for quantifiers. +BaseModel +--------- + +The ``insertBatch()`` and ``updateBatch()`` methods now honor model settings like +``updateOnlyChanged`` and ``allowEmptyInserts``. This change ensures consistent handling +across all insert/update operations. + Interface Changes ================= @@ -45,6 +52,7 @@ Removed Deprecated Items ======================== - **Text Helper:** The deprecated types in ``random_string()`` function: ``basic``, ``md5``, and ``sha1`` has been removed. +- **BaseModel:** The deprecated method ``transformDataRowToArray()`` has been removed. ************ Enhancements @@ -56,7 +64,7 @@ Libraries - **CLI:** Added ``SignalTrait`` to provide unified handling of operating system signals in CLI commands. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. -- **CURLRequest:** Added ``fresh_connect`` options to enable/disabled request fresh connection. +- **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. @@ -71,7 +79,7 @@ Testing Database ======== -- **Exception Logging:** All DB drivers now log database exceptions uniformly. Previously, each driver has its own log format. +- **Exception Logging:** All DB drivers now log database exceptions uniformly. Previously, each driver had its own log format. Query Builder ------------- diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index c124b9b496bb..48a8f1e1f48d 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -473,42 +473,42 @@ parameters: path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:351\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php From 3b7018c3f5bd900bd82bcf58dc216022a7a54d2c Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 9 Sep 2025 02:14:07 +0800 Subject: [PATCH 29/84] refactor: add the `SensitiveParameter` attribute to methods dealing with sensitive info (#9710) --- system/Encryption/EncrypterInterface.php | 5 +++-- system/Encryption/Handlers/OpenSSLHandler.php | 5 +++-- system/Encryption/Handlers/SodiumHandler.php | 5 +++-- system/HTTP/CURLRequest.php | 9 +++------ system/HTTP/URI.php | 3 ++- system/Security/Security.php | 5 +++-- user_guide_src/source/changelogs/v4.7.0.rst | 11 +++++++++++ 7 files changed, 28 insertions(+), 15 deletions(-) diff --git a/system/Encryption/EncrypterInterface.php b/system/Encryption/EncrypterInterface.php index 2f40995041f3..9c1b0d36d1be 100644 --- a/system/Encryption/EncrypterInterface.php +++ b/system/Encryption/EncrypterInterface.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Encryption; use CodeIgniter\Encryption\Exceptions\EncryptionException; +use SensitiveParameter; /** * CodeIgniter Encryption Handler @@ -32,7 +33,7 @@ interface EncrypterInterface * * @throws EncryptionException */ - public function encrypt($data, $params = null); + public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null); /** * Decrypt - convert ciphertext into plaintext @@ -44,5 +45,5 @@ public function encrypt($data, $params = null); * * @throws EncryptionException */ - public function decrypt($data, $params = null); + public function decrypt($data, #[SensitiveParameter] $params = null); } diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 9745160b8003..9dca5b90304c 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Encryption\Handlers; use CodeIgniter\Encryption\Exceptions\EncryptionException; +use SensitiveParameter; /** * Encryption handling for OpenSSL library @@ -79,7 +80,7 @@ class OpenSSLHandler extends BaseHandler /** * {@inheritDoc} */ - public function encrypt($data, $params = null) + public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) { // Allow key override if ($params !== null) { @@ -115,7 +116,7 @@ public function encrypt($data, $params = null) /** * {@inheritDoc} */ - public function decrypt($data, $params = null) + public function decrypt($data, #[SensitiveParameter] $params = null) { // Allow key override if ($params !== null) { diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index c74f2a5f0a48..45f9ac2fa383 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Encryption\Handlers; use CodeIgniter\Encryption\Exceptions\EncryptionException; +use SensitiveParameter; /** * SodiumHandler uses libsodium in encryption. @@ -40,7 +41,7 @@ class SodiumHandler extends BaseHandler /** * {@inheritDoc} */ - public function encrypt($data, $params = null) + public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) { $this->parseParams($params); @@ -71,7 +72,7 @@ public function encrypt($data, $params = null) /** * {@inheritDoc} */ - public function decrypt($data, $params = null) + public function decrypt($data, #[SensitiveParameter] $params = null) { $this->parseParams($params); diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 7014ca418a43..29516a094a86 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -18,6 +18,7 @@ use Config\App; use Config\CURLRequest as ConfigCURLRequest; use CurlShareHandle; +use SensitiveParameter; /** * A lightweight HTTP client for sending synchronous HTTP requests via cURL. @@ -260,13 +261,9 @@ public function put(string $url, array $options = []): ResponseInterface * * @return $this */ - public function setAuth(string $username, string $password, string $type = 'basic') + public function setAuth(string $username, #[SensitiveParameter] string $password, string $type = 'basic') { - $this->config['auth'] = [ - $username, - $password, - $type, - ]; + $this->config['auth'] = [$username, $password, $type]; return $this; } diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index 5bcf11de655a..c904ff8a549a 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -17,6 +17,7 @@ use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; +use SensitiveParameter; use Stringable; /** @@ -768,7 +769,7 @@ public function withScheme(string $scheme) * * @TODO PSR-7: Should be `withUserInfo($user, $password = null)`. */ - public function setUserInfo(string $user, string $pass) + public function setUserInfo(string $user, #[SensitiveParameter] string $pass) { $this->user = trim($user); $this->password = trim($pass); diff --git a/system/Security/Security.php b/system/Security/Security.php index aa744d9ed343..c367d2d3b412 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -26,6 +26,7 @@ use Config\Cookie as CookieConfig; use Config\Security as SecurityConfig; use ErrorException; +use SensitiveParameter; /** * Class Security @@ -371,13 +372,13 @@ protected function randomize(string $hash): string * * @throws InvalidArgumentException "hex2bin(): Hexadecimal input string must have an even length" */ - protected function derandomize(string $token): string + protected function derandomize(#[SensitiveParameter] string $token): string { $key = substr($token, -static::CSRF_HASH_BYTES * 2); $value = substr($token, 0, static::CSRF_HASH_BYTES * 2); try { - return bin2hex(hex2bin($value) ^ hex2bin($key)); + return bin2hex((string) hex2bin($value) ^ (string) hex2bin($key)); } catch (ErrorException $e) { // "hex2bin(): Hexadecimal input string must have an even length" throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index a9c888592555..fd64911a6561 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -48,6 +48,17 @@ Interface Changes Method Signature Changes ======================== +- Added the ``SensitiveParameter`` attribute to various methods to conceal sensitive information from stack traces. Affected methods are: + - ``CodeIgniter\Encryption\EncrypterInterface::encrypt()`` + - ``CodeIgniter\Encryption\EncrypterInterface::decrypt()`` + - ``CodeIgniter\Encryption\Handlers\OpenSSLHandler::encrypt()`` + - ``CodeIgniter\Encryption\Handlers\OpenSSLHandler::decrypt()`` + - ``CodeIgniter\Encryption\Handlers\SodiumHandler::encrypt()`` + - ``CodeIgniter\Encryption\Handlers\SodiumHandler::decrypt()`` + - ``CodeIgniter\HTTP\CURLRequest::setAuth()`` + - ``CodeIgniter\HTTP\URI::setUserInfo()`` + - ``CodeIgniter\Security\Security::derandomize()`` + Removed Deprecated Items ======================== From 3473349b66bcb33d26cfedb655ca15c13984d462 Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Sat, 13 Sep 2025 20:39:08 +0300 Subject: [PATCH 30/84] fix: Remove check ext-json (#9713) * fix: Remove check ext-json * refactor: Remove deprecated `CodeIgniter::resolvePlatformExtensions()` * docs: Update userguide * fix: Change section for docs --- system/Boot.php | 1 - system/CodeIgniter.php | 33 -------------------- user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/intro/requirements.rst | 3 +- 4 files changed, 2 insertions(+), 36 deletions(-) diff --git a/system/Boot.php b/system/Boot.php index 5b228664146f..85b983c19d89 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -304,7 +304,6 @@ protected static function checkMissingExtensions(): void foreach ([ 'intl', - 'json', 'mbstring', ] as $extension) { if (! extension_loaded($extension)) { diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index c87ff78e8348..26a64575cc98 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -15,7 +15,6 @@ use CodeIgniter\Cache\ResponseCache; use CodeIgniter\Debug\Timer; use CodeIgniter\Events\Events; -use CodeIgniter\Exceptions\FrameworkException; use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Filters\Filters; @@ -192,38 +191,6 @@ public function initialize() date_default_timezone_set($this->config->appTimezone ?? 'UTC'); } - /** - * Checks system for missing required PHP extensions. - * - * @return void - * - * @throws FrameworkException - * - * @codeCoverageIgnore - * - * @deprecated 4.5.0 Moved to system/bootstrap.php. - */ - protected function resolvePlatformExtensions() - { - $requiredExtensions = [ - 'intl', - 'json', - 'mbstring', - ]; - - $missingExtensions = []; - - foreach ($requiredExtensions as $extension) { - if (! extension_loaded($extension)) { - $missingExtensions[] = $extension; - } - } - - if ($missingExtensions !== []) { - throw FrameworkException::forMissingExtension(implode(', ', $missingExtensions)); - } - } - /** * Initializes Kint * diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index fd64911a6561..9399095c1d44 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -64,6 +64,7 @@ Removed Deprecated Items - **Text Helper:** The deprecated types in ``random_string()`` function: ``basic``, ``md5``, and ``sha1`` has been removed. - **BaseModel:** The deprecated method ``transformDataRowToArray()`` has been removed. +- **CodeIgniter:** The deprecated ``CodeIgniter\CodeIgniter::resolvePlatformExtensions()`` has been removed. ************ Enhancements diff --git a/user_guide_src/source/intro/requirements.rst b/user_guide_src/source/intro/requirements.rst index 4c956e631f0e..2de0b5c5b7d3 100644 --- a/user_guide_src/source/intro/requirements.rst +++ b/user_guide_src/source/intro/requirements.rst @@ -13,8 +13,7 @@ PHP and Required Extensions `PHP `_ version 8.2 or newer is required, with the following PHP extensions are enabled: - `intl `_ - - `mbstring `_ - - `json `_ + - `mbstring `_ .. warning:: - The end of life date for PHP 7.4 was November 28, 2022. From 6824b69aa4355d4efc5e767dcb199dfcbd70c09e Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Wed, 8 Oct 2025 05:39:55 -0500 Subject: [PATCH 31/84] feat(app): Added controller attributes (#9745) * feat(app): Added controller attributes * chore(app): Fix test Group use statements * build(app): Fixing style and sa issues * build(app): Fixing more analyses errors * build(app): Additional fixes * CS fixes * feat(app): Add config setting to use controller attributes or not * code fixes * Apply suggestions from code review Co-authored-by: Michal Sniatala * qa fixes * Replace md5 in cache attribute with xxh128 * cs fix --------- Co-authored-by: Michal Sniatala --- app/Config/Routing.php | 9 + composer.json | 4 +- system/CodeIgniter.php | 35 +- system/Config/Routing.php | 9 + system/Router/Attributes/Cache.php | 143 ++++++ system/Router/Attributes/Filter.php | 67 +++ system/Router/Attributes/Restrict.php | 197 ++++++++ .../Attributes/RouteAttributeInterface.php | 39 ++ system/Router/Router.php | 151 +++++- .../Controllers/AttributeController.php | 90 ++++ .../Router/Filters/TestAttributeFilter.php | 38 ++ tests/system/CodeIgniterTest.php | 268 ++++++++++ tests/system/Router/Attributes/CacheTest.php | 215 ++++++++ tests/system/Router/Attributes/FilterTest.php | 222 +++++++++ .../system/Router/Attributes/RestrictTest.php | 464 ++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 2 + .../source/incoming/controller_attributes.rst | 71 +++ .../incoming/controller_attributes/001.php | 14 + .../incoming/controller_attributes/002.php | 16 + .../incoming/controller_attributes/003.php | 26 + .../incoming/controller_attributes/004.php | 28 ++ .../incoming/controller_attributes/005.php | 22 + .../incoming/controller_attributes/006.php | 51 ++ .../incoming/controller_attributes/007.php | 52 ++ user_guide_src/source/incoming/index.rst | 1 + .../phpstan-baseline/assign.propertyType.neon | 7 +- .../greaterOrEqual.alwaysTrue.neon | 4 + utils/phpstan-baseline/isset.property.neon | 43 ++ utils/phpstan-baseline/loader.neon | 4 +- .../method.alreadyNarrowedType.neon | 12 +- .../missingType.iterableValue.neon | 27 +- .../nullCoalesce.property.neon | 17 +- utils/phpstan-baseline/return.type.neon | 8 + 33 files changed, 2347 insertions(+), 9 deletions(-) create mode 100644 system/Router/Attributes/Cache.php create mode 100644 system/Router/Attributes/Filter.php create mode 100644 system/Router/Attributes/Restrict.php create mode 100644 system/Router/Attributes/RouteAttributeInterface.php create mode 100644 tests/_support/Router/Controllers/AttributeController.php create mode 100644 tests/_support/Router/Filters/TestAttributeFilter.php create mode 100644 tests/system/Router/Attributes/CacheTest.php create mode 100644 tests/system/Router/Attributes/FilterTest.php create mode 100644 tests/system/Router/Attributes/RestrictTest.php create mode 100644 user_guide_src/source/incoming/controller_attributes.rst create mode 100644 user_guide_src/source/incoming/controller_attributes/001.php create mode 100644 user_guide_src/source/incoming/controller_attributes/002.php create mode 100644 user_guide_src/source/incoming/controller_attributes/003.php create mode 100644 user_guide_src/source/incoming/controller_attributes/004.php create mode 100644 user_guide_src/source/incoming/controller_attributes/005.php create mode 100644 user_guide_src/source/incoming/controller_attributes/006.php create mode 100644 user_guide_src/source/incoming/controller_attributes/007.php create mode 100644 utils/phpstan-baseline/greaterOrEqual.alwaysTrue.neon create mode 100644 utils/phpstan-baseline/isset.property.neon create mode 100644 utils/phpstan-baseline/return.type.neon diff --git a/app/Config/Routing.php b/app/Config/Routing.php index 3005543a9e79..5aeee51d212d 100644 --- a/app/Config/Routing.php +++ b/app/Config/Routing.php @@ -96,6 +96,15 @@ class Routing extends BaseRouting */ public bool $autoRoute = false; + /** + * If TRUE, the system will look for attributes on controller + * class and methods that can run before and after the + * controller/method. + * + * If FALSE, will ignore any attributes. + */ + public bool $useControllerAttributes = true; + /** * For Defined Routes. * If TRUE, will enable the use of the 'prioritize' option diff --git a/composer.json b/composer.json index 47efbda75718..b4e38c92e38b 100644 --- a/composer.json +++ b/composer.json @@ -117,10 +117,10 @@ "phpstan:baseline": [ "bash -c \"rm -rf utils/phpstan-baseline/*.neon\"", "bash -c \"touch utils/phpstan-baseline/loader.neon\"", - "phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon", + "phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon --memory-limit=512M", "split-phpstan-baseline utils/phpstan-baseline/loader.neon" ], - "phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi", + "phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi --memory-limit=512M", "sa": "@analyze", "style": "@cs-fix", "test": "phpunit" diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 26a64575cc98..2219449e71f3 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -25,6 +25,7 @@ use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponsableInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\URI; @@ -460,8 +461,12 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache $returned = $this->startController(); + // If startController returned a Response (from an attribute or Closure), use it + if ($returned instanceof ResponseInterface) { + $this->gatherOutput($cacheConfig, $returned); + } // Closure controller has run in startController(). - if (! is_callable($this->controller)) { + elseif (! is_callable($this->controller)) { $controller = $this->createController(); if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) { @@ -497,6 +502,13 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache } } + // Execute controller attributes' after() methods AFTER framework filters + if ((config('Routing')->useControllerAttributes ?? true) === true) { + $this->benchmark->start('route_attributes_after'); + $this->response = $this->router->executeAfterAttributes($this->request, $this->response); + $this->benchmark->stop('route_attributes_after'); + } + // Skip unnecessary processing for special Responses. if ( ! $this->response instanceof DownloadResponse @@ -855,6 +867,27 @@ protected function startController() throw PageNotFoundException::forControllerNotFound($this->controller, $this->method); } + // Execute route attributes' before() methods + // This runs after routing/validation but BEFORE expensive controller instantiation + if ((config('Routing')->useControllerAttributes ?? true) === true) { + $this->benchmark->start('route_attributes_before'); + $attributeResponse = $this->router->executeBeforeAttributes($this->request); + $this->benchmark->stop('route_attributes_before'); + + // If attribute returns a Response, short-circuit + if ($attributeResponse instanceof ResponseInterface) { + $this->benchmark->stop('controller_constructor'); + $this->benchmark->stop('controller'); + + return $attributeResponse; + } + + // If attribute returns a modified Request, use it + if ($attributeResponse instanceof RequestInterface) { + $this->request = $attributeResponse; + } + } + return null; } diff --git a/system/Config/Routing.php b/system/Config/Routing.php index 6999ad3c5b3c..4db55b3aa0c2 100644 --- a/system/Config/Routing.php +++ b/system/Config/Routing.php @@ -96,6 +96,15 @@ class Routing extends BaseConfig */ public bool $autoRoute = false; + /** + * If TRUE, the system will look for attributes on controller + * class and methods that can run before and after the + * controller/method. + * + * If FALSE, will ignore any attributes. + */ + public bool $useControllerAttributes = true; + /** * For Defined Routes. * If TRUE, will enable the use of the 'prioritize' option diff --git a/system/Router/Attributes/Cache.php b/system/Router/Attributes/Cache.php new file mode 100644 index 000000000000..5bf083f5fe4a --- /dev/null +++ b/system/Router/Attributes/Cache.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use Attribute; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Cache Attribute + * + * Caches the response of a controller method at the server level for a specified duration. + * This is server-side caching to avoid expensive operations, not browser-level caching. + * + * Usage: + * ```php + * #[Cache(for: 3600)] // Cache for 1 hour + * #[Cache(for: 300, key: 'custom_key')] // Cache with custom key + * ``` + * + * Limitations: + * - Only caches GET requests; POST, PUT, DELETE, and other methods are ignored + * - Streaming responses or file downloads may not cache properly + * - Cache key includes HTTP method, path, query string, and possibly user_id(), but not request headers + * - Does not automatically invalidate related cache entries + * - Cookies set in the response are cached and reused for all subsequent requests + * - Large responses may impact cache storage performance + * - Browser Cache-Control headers do not affect server-side caching behavior + * + * Security Considerations: + * - Ensure cache backend is properly secured and not accessible publicly + * - Be aware that authorization checks happen before cache lookup + */ +#[Attribute(Attribute::TARGET_METHOD)] +class Cache implements RouteAttributeInterface +{ + public function __construct( + public int $for = 3600, + public ?string $key = null, + ) { + } + + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null + { + // Only cache GET requests + if ($request->getMethod() !== 'GET') { + return null; + } + + // Check cache before controller execution + $cacheKey = $this->key ?? $this->generateCacheKey($request); + + $cached = cache($cacheKey); + // Validate cached data structure + if ($cached !== null && (is_array($cached) && isset($cached['body'], $cached['headers'], $cached['status']))) { + $response = service('response'); + $response->setBody($cached['body']); + $response->setStatusCode($cached['status']); + // Mark response as served from cache to prevent re-caching + $response->setHeader('X-Cached-Response', 'true'); + + // Restore headers from cached array of header name => value strings + foreach ($cached['headers'] as $name => $value) { + $response->setHeader($name, $value); + } + $response->setHeader('Age', (string) (time() - ($cached['timestamp'] ?? time()))); + + return $response; + } + + return null; // Continue to controller + } + + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + // Don't re-cache if response was already served from cache + if ($response->hasHeader('X-Cached-Response')) { + // Remove the marker header before sending response + $response->removeHeader('X-Cached-Response'); + + return null; + } + + // Only cache GET requests + if ($request->getMethod() !== 'GET') { + return null; + } + + $cacheKey = $this->key ?? $this->generateCacheKey($request); + + // Convert Header objects to strings for caching + $headers = []; + + foreach ($response->headers() as $name => $header) { + // Handle both single Header and array of Headers + if (is_array($header)) { + // Multiple headers with same name + $values = []; + + foreach ($header as $h) { + $values[] = $h->getValueLine(); + } + $headers[$name] = implode(', ', $values); + } else { + // Single header + $headers[$name] = $header->getValueLine(); + } + } + + $data = [ + 'body' => $response->getBody(), + 'headers' => $headers, + 'status' => $response->getStatusCode(), + 'timestamp' => time(), + ]; + + cache()->save($cacheKey, $data, $this->for); + + return $response; + } + + protected function generateCacheKey(RequestInterface $request): string + { + return 'route_cache_' . hash( + 'xxh128', + $request->getMethod() . + $request->getUri()->getPath() . + $request->getUri()->getQuery() . + (function_exists('user_id') ? user_id() : ''), + ); + } +} diff --git a/system/Router/Attributes/Filter.php b/system/Router/Attributes/Filter.php new file mode 100644 index 000000000000..33794e1711bf --- /dev/null +++ b/system/Router/Attributes/Filter.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use Attribute; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Filter Attribute + * + * Applies CodeIgniter filters to controller classes or methods. Filters can perform + * operations before or after controller execution, such as authentication, CSRF protection, + * rate limiting, or request/response manipulation. + * + * Limitations: + * - Filter must be registered in Config\Filters.php or won't be found + * - Does not validate filter existence at attribute definition time + * - Cannot conditionally apply filters based on runtime conditions + * - Class-level filters cannot be overridden or disabled for specific methods + * + * Security Considerations: + * - Filters run in the order specified; authentication should typically come first + * - Don't rely solely on filters for critical security; validate in controllers too + * - Ensure sensitive filters are registered as globals if they should apply site-wide + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Filter implements RouteAttributeInterface +{ + public function __construct( + public string $by, + public array $having = [], + ) { + } + + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null + { + // Filters are handled by the filter system via getFilters() + // No processing needed here + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + return null; + } + + public function getFilters(): array + { + if ($this->having === []) { + return [$this->by]; + } + + return [$this->by => $this->having]; + } +} diff --git a/system/Router/Attributes/Restrict.php b/system/Router/Attributes/Restrict.php new file mode 100644 index 000000000000..e8738befd31e --- /dev/null +++ b/system/Router/Attributes/Restrict.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use Attribute; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * Restrict Attribute + * + * Restricts access to controller methods or entire controllers based on environment, + * hostname, or subdomain conditions. Throws PageNotFoundException when restrictions + * are not met. + * + * Limitations: + * - Throws PageNotFoundException (404) for all restriction failures + * - Cannot provide custom error messages or HTTP status codes + * - Subdomain detection may not work correctly behind proxies without proper configuration + * - Does not support wildcard or regex patterns for hostnames + * - Cannot restrict based on request headers, IP addresses, or user authentication + * + * Security Considerations: + * - Environment checks rely on the ENVIRONMENT constant being correctly set + * - Hostname restrictions can be bypassed if Host header is not validated at web server level + * - Should not be used as the sole security mechanism for sensitive operations + * - Consider additional authorization checks for critical endpoints + * - Does not prevent direct access if routes are exposed through other means + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Restrict implements RouteAttributeInterface +{ + private const TWO_PART_TLDS = [ + 'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk', + 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au', + 'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp', + 'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz', + 'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in', + 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', + 'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg', + 'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za', + 'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr', + 'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th', + 'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my', + 'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx', + 'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br', + 'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il', + 'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id', + 'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk', + 'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw', + 'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa', + 'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae', + 'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr', + 'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke', + 'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng', + 'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk', + 'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg', + 'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy', + 'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk', + 'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd', + 'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar', + 'gob.cl', + ]; + + public function __construct( + public array|string|null $environment = null, + public array|string|null $hostname = null, + public array|string|null $subdomain = null, + ) { + } + + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null + { + $this->checkEnvironment(); + $this->checkHostname($request); + $this->checkSubdomain($request); + + return null; // Continue normal execution + } + + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + return null; // No post-processing needed + } + + protected function checkEnvironment(): void + { + if ($this->environment === null || $this->environment === []) { + return; + } + + $currentEnv = ENVIRONMENT; + $allowed = []; + $denied = []; + + foreach ((array) $this->environment as $env) { + if (str_starts_with($env, '!')) { + $denied[] = substr($env, 1); + } else { + $allowed[] = $env; + } + } + + // Check denied environments first (explicit deny takes precedence) + if ($denied !== [] && in_array($currentEnv, $denied, true)) { + throw new PageNotFoundException('Access denied: Current environment is blocked.'); + } + + // If allowed list exists, current env must be in it + // If no allowed list (only denials), then all non-denied envs are allowed + if ($allowed !== [] && ! in_array($currentEnv, $allowed, true)) { + throw new PageNotFoundException('Access denied: Current environment is not allowed.'); + } + } + + private function checkHostname(RequestInterface $request): void + { + if ($this->hostname === null || $this->hostname === []) { + return; + } + + $currentHost = strtolower($request->getUri()->getHost()); + $allowedHosts = array_map('strtolower', (array) $this->hostname); + + if (! in_array($currentHost, $allowedHosts, true)) { + throw new PageNotFoundException('Access denied: Host is not allowed.'); + } + } + + private function checkSubdomain(RequestInterface $request): void + { + if ($this->subdomain === null || $this->subdomain === []) { + return; + } + + $currentSubdomain = $this->getSubdomain($request); + $allowedSubdomains = array_map('strtolower', (array) $this->subdomain); + + // If no subdomain exists but one is required + if ($currentSubdomain === '') { + throw new PageNotFoundException('Access denied: Subdomain required'); + } + + // Check if the current subdomain is allowed + if (! in_array($currentSubdomain, $allowedSubdomains, true)) { + throw new PageNotFoundException('Access denied: subdomain is blocked.'); + } + } + + private function getSubdomain(RequestInterface $request): string + { + $host = strtolower($request->getUri()->getHost()); + + // Handle localhost and IP addresses - they don't have subdomains + if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) { + return ''; + } + + $parts = explode('.', $host); + $partCount = count($parts); + + // Need at least 3 parts for a subdomain (subdomain.domain.tld) + // e.g., api.example.com + if ($partCount < 3) { + return ''; + } + // Check if we have a two-part TLD (e.g., co.uk, com.au) + $lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1]; + if (in_array($lastTwoParts, self::TWO_PART_TLDS, true)) { + // For two-part TLD, need at least 4 parts for subdomain + // e.g., api.example.co.uk (4 parts) + if ($partCount < 4) { + return ''; // No subdomain, just domain.co.uk + } + + // Remove the two-part TLD and domain name (last 3 parts) + // e.g., admin.api.example.co.uk -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 3)); + } + + // Standard TLD: Remove TLD and domain (last 2 parts) + // e.g., admin.api.example.com -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 2)); + } +} diff --git a/system/Router/Attributes/RouteAttributeInterface.php b/system/Router/Attributes/RouteAttributeInterface.php new file mode 100644 index 000000000000..ac735ef9a854 --- /dev/null +++ b/system/Router/Attributes/RouteAttributeInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +interface RouteAttributeInterface +{ + /** + * Process the attribute before the controller is executed. + * + * @return RequestInterface|ResponseInterface|null + * Return RequestInterface to replace the request + * Return ResponseInterface to short-circuit and send response + * Return null to continue normal execution + */ + public function before(RequestInterface $request): RequestInterface|ResponseInterface|null; + + /** + * Process the attribute after the controller is executed. + * + * @return ResponseInterface|null + * Return ResponseInterface to replace the response + * Return null to use the existing response + */ + public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface; +} diff --git a/system/Router/Router.php b/system/Router/Router.php index eb75e33adc3e..f2b291e58bbb 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -19,11 +19,16 @@ use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Router\Attributes\Filter; +use CodeIgniter\Router\Attributes\RouteAttributeInterface; use CodeIgniter\Router\Exceptions\RouterException; use Config\App; use Config\Feature; use Config\Routing; +use ReflectionClass; +use Throwable; /** * Request router. @@ -131,6 +136,13 @@ class Router implements RouterInterface protected ?AutoRouterInterface $autoRouter = null; + /** + * Route attributes collected during routing for the current route. + * + * @var array{class: list, method: list} + */ + protected array $routeAttributes = ['class' => [], 'method' => []]; + /** * Permitted URI chars * @@ -215,6 +227,8 @@ public function handle(?string $uri = null) $this->filtersInfo = $this->collection->getFiltersForRoute($this->matchedRoute[0]); } + $this->processRouteAttributes(); + return $this->controller; } @@ -230,6 +244,8 @@ public function handle(?string $uri = null) // Checks auto routes $this->autoRoute($uri); + $this->processRouteAttributes(); + return $this->controllerName(); } @@ -240,7 +256,18 @@ public function handle(?string $uri = null) */ public function getFilters(): array { - return $this->filtersInfo; + $filters = $this->filtersInfo; + + // Check for attribute-based filters + foreach ($this->routeAttributes as $attributes) { + foreach ($attributes as $attribute) { + if ($attribute instanceof Filter) { + $filters = array_merge($filters, $attribute->getFilters()); + } + } + } + + return $filters; } /** @@ -744,4 +771,126 @@ private function checkDisallowedChars(string $uri): void } } } + + /** + * Extracts PHP attributes from the resolved controller and method. + */ + private function processRouteAttributes(): void + { + $this->routeAttributes = ['class' => [], 'method' => []]; + + // Skip if controller attributes are disabled in config + if (config('routing')->useControllerAttributes === false) { + return; + } + + // Skip if controller is a Closure + if ($this->controller instanceof Closure) { + return; + } + + if (! class_exists($this->controller)) { + return; + } + + $reflectionClass = new ReflectionClass($this->controller); + + // Process class-level attributes + foreach ($reflectionClass->getAttributes() as $attribute) { + try { + $instance = $attribute->newInstance(); + + if ($instance instanceof RouteAttributeInterface) { + $this->routeAttributes['class'][] = $instance; + } + } catch (Throwable) { + log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName()); + } + } + + if ($this->method === '' || $this->method === null) { + return; + } + + // Process method-level attributes + if ($reflectionClass->hasMethod($this->method)) { + $reflectionMethod = $reflectionClass->getMethod($this->method); + + foreach ($reflectionMethod->getAttributes() as $attribute) { + try { + $instance = $attribute->newInstance(); + + if ($instance instanceof RouteAttributeInterface) { + $this->routeAttributes['method'][] = $instance; + } + } catch (Throwable) { + // Skip attributes that fail to instantiate + log_message('error', 'Failed to instantiate attribute: ' . $attribute->getName()); + } + } + } + } + + /** + * Execute beforeController() on all route attributes. + * Called by CodeIgniter before controller execution. + */ + public function executeBeforeAttributes(RequestInterface $request): RequestInterface|ResponseInterface|null + { + // Process class-level attributes first, then method-level + foreach (['class', 'method'] as $level) { + foreach ($this->routeAttributes[$level] as $attribute) { + if (! $attribute instanceof RouteAttributeInterface) { + continue; + } + + $result = $attribute->before($request); + + // If attribute returns a Response, short-circuit + if ($result instanceof ResponseInterface) { + return $result; + } + + // If attribute returns a Request, use it + if ($result instanceof RequestInterface) { + $request = $result; + } + } + } + + return $request; + } + + /** + * Execute afterController() on all route attributes. + * Called by CodeIgniter after controller execution. + */ + public function executeAfterAttributes(RequestInterface $request, ResponseInterface $response): ResponseInterface + { + // Process in reverse order: method-level first, then class-level + foreach (array_reverse(['class', 'method']) as $level) { + foreach ($this->routeAttributes[$level] as $attribute) { + if ($attribute instanceof RouteAttributeInterface) { + $result = $attribute->after($request, $response); + + if ($result instanceof ResponseInterface) { + $response = $result; + } + } + } + } + + return $response; + } + + /** + * Returns the route attributes collected during routing + * for the current route. + * + * @return array{class: list, method: list} + */ + public function getRouteAttributes(): array + { + return $this->routeAttributes; + } } diff --git a/tests/_support/Router/Controllers/AttributeController.php b/tests/_support/Router/Controllers/AttributeController.php new file mode 100644 index 000000000000..0c43404a2a8e --- /dev/null +++ b/tests/_support/Router/Controllers/AttributeController.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Router\Controllers; + +use CodeIgniter\Controller; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Router\Attributes\Cache; +use CodeIgniter\Router\Attributes\Filter; +use CodeIgniter\Router\Attributes\Restrict; + +class AttributeController extends Controller +{ + /** + * Test method with Cache attribute + */ + #[Cache(for: 60)] + public function cached(): ResponseInterface + { + return $this->response->setBody('Cached content at ' . time()); + } + + /** + * Test method with Filter attribute + */ + #[Filter(by: 'testAttributeFilter')] + public function filtered(): ResponseInterface + { + $body = $this->request->getBody(); + + return $this->response->setBody('Filtered: ' . $body); + } + + /** + * Test method with Restrict attribute (environment) + */ + #[Restrict(environment: ENVIRONMENT)] + public function restricted(): ResponseInterface + { + return $this->response->setBody('Access granted'); + } + + /** + * Test method with multiple attributes + */ + #[Filter(by: 'testAttributeFilter')] + #[Restrict(environment: ENVIRONMENT)] + public function multipleAttributes(): ResponseInterface + { + $body = $this->request->getBody(); + + return $this->response->setBody('Multiple: ' . $body); + } + + /** + * Test method that should be restricted + */ + #[Restrict(environment: 'production')] + public function shouldBeRestricted(): ResponseInterface + { + return $this->response->setBody('Should not see this'); + } + + /** + * Test method with custom cache key + */ + #[Cache(for: 60, key: 'custom_cache_key')] + public function customCacheKey(): ResponseInterface + { + return $this->response->setBody('Custom key content at ' . time()); + } + + /** + * Simple method with no attributes + */ + public function noAttributes(): ResponseInterface + { + return $this->response->setBody('No attributes'); + } +} diff --git a/tests/_support/Router/Filters/TestAttributeFilter.php b/tests/_support/Router/Filters/TestAttributeFilter.php new file mode 100644 index 000000000000..dbafc230ec3e --- /dev/null +++ b/tests/_support/Router/Filters/TestAttributeFilter.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Router\Filters; + +use CodeIgniter\Filters\FilterInterface; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +class TestAttributeFilter implements FilterInterface +{ + public function before(RequestInterface $request, $arguments = null) + { + // Modify request body to indicate filter ran + $request->setBody('before_filter_ran:'); + + return $request; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // Append to response body to indicate filter ran + $body = $response->getBody(); + $response->setBody($body . ':after_filter_ran'); + + return $response; + } +} diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index a4216391b7ee..e3acab514cea 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter; use App\Controllers\Home; +use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\Debug\Timer; use CodeIgniter\Exceptions\PageNotFoundException; @@ -36,6 +37,7 @@ use PHPUnit\Framework\Attributes\WithoutErrorHandler; use Tests\Support\Filters\Customfilter; use Tests\Support\Filters\RedirectFilter; +use Tests\Support\Router\Filters\TestAttributeFilter; /** * @internal @@ -954,6 +956,15 @@ public function testStartControllerPermitsInvoke(): void { $this->setPrivateProperty($this->codeigniter, 'benchmark', new Timer()); $this->setPrivateProperty($this->codeigniter, 'controller', '\\' . Home::class); + + // Set up the request and router + $request = service('incomingrequest'); + $this->setPrivateProperty($this->codeigniter, 'request', $request); + + $routes = service('routes'); + $router = service('router', $routes, $request); + $this->setPrivateProperty($this->codeigniter, 'router', $router); + $startController = self::getPrivateMethodInvoker($this->codeigniter, 'startController'); $this->setPrivateProperty($this->codeigniter, 'method', '__invoke'); @@ -962,4 +973,261 @@ public function testStartControllerPermitsInvoke(): void // No PageNotFoundException $this->assertTrue(true); } + + public function testRouteAttributeCacheIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/cached']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/cached'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + + // Clear cache before test + cache()->clean(); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/cached', '\Tests\Support\Router\Controllers\AttributeController::cached'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + // First request - should cache + ob_start(); + $this->codeigniter->run(); + $output1 = ob_get_clean(); + + $this->assertStringContainsString('Cached content at', (string) $output1); + + // Extract timestamp from first response + preg_match('/Cached content at (\d+)/', (string) $output1, $matches1); + $time1 = $matches1[1] ?? null; + + // Wait a moment to ensure time would be different if not cached + sleep(1); + + // Second request - should return cached version with same timestamp + $this->resetServices(); + $_SERVER['argv'] = ['index.php', 'attribute/cached']; + $_SERVER['argc'] = 2; + Services::superglobals()->setServer('REQUEST_URI', '/attribute/cached'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + $this->codeigniter = new MockCodeIgniter(new App()); + + $routes = service('routes'); + $routes->get('attribute/cached', '\Tests\Support\Router\Controllers\AttributeController::cached'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output2 = ob_get_clean(); + + preg_match('/Cached content at (\d+)/', (string) $output2, $matches2); + $time2 = $matches2[1] ?? null; + + // Timestamps should be EXACTLY the same (cached response) + $this->assertSame($time1, $time2, 'Expected cached response with identical timestamp'); + + // Clear cache after test + cache()->clean(); + } + + public function testRouteAttributeFilterIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/filtered']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/filtered'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Register the test filter + $filterConfig = config('Filters'); + $filterConfig->aliases['testAttributeFilter'] = TestAttributeFilter::class; + service('filters', $filterConfig); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/filtered', '\Tests\Support\Router\Controllers\AttributeController::filtered'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Verify filter ran before (modified request body) and after (appended to response) + $this->assertStringContainsString('Filtered: before_filter_ran:', (string) $output); + $this->assertStringContainsString(':after_filter_ran', (string) $output); + } + + public function testRouteAttributeRestrictIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/restricted']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/restricted'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/restricted', '\Tests\Support\Router\Controllers\AttributeController::restricted'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Should allow access since we're in the current ENVIRONMENT + $this->assertStringContainsString('Access granted', (string) $output); + } + + public function testRouteAttributeRestrictThrowsException(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/restricted']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/shouldBeRestricted'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/shouldBeRestricted', '\Tests\Support\Router\Controllers\AttributeController::shouldBeRestricted'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + // Should throw PageNotFoundException because we're not in 'production' + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is not allowed.'); + + $this->codeigniter->run(); + } + + public function testRouteAttributeMultipleAttributesIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/multiple']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/multiple'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Register the test filter + $filterConfig = config('Filters'); + $filterConfig->aliases['testAttributeFilter'] = TestAttributeFilter::class; + service('filters', $filterConfig); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/multiple', '\Tests\Support\Router\Controllers\AttributeController::multipleAttributes'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Verify both Restrict and Filter attributes worked + $this->assertStringContainsString('Multiple: before_filter_ran:', (string) $output); + $this->assertStringContainsString(':after_filter_ran', (string) $output); + } + + public function testRouteAttributeNoAttributesIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/none']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/none'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/none', '\Tests\Support\Router\Controllers\AttributeController::noAttributes'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Should work normally with no attribute processing + $this->assertStringContainsString('No attributes', (string) $output); + } + + public function testRouteAttributeCustomCacheKeyIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/customkey']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/customkey'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + + // Clear cache before test + cache()->clean(); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/customkey', '\Tests\Support\Router\Controllers\AttributeController::customCacheKey'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + // First request + ob_start(); + $this->codeigniter->run(); + ob_get_clean(); + + // Verify custom cache key was used + $cached = cache('custom_cache_key'); + $this->assertNotNull($cached); + $this->assertIsArray($cached); + $this->assertArrayHasKey('body', $cached); + $this->assertStringContainsString('Custom key content at', (string) $cached['body']); + + // Clear cache after test + cache()->clean(); + } + + public function testRouteAttributesDisabledInConfig(): void + { + Services::superglobals()->setServer('REQUEST_URI', '/attribute/filtered'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + + // Disable route attributes in config BEFORE creating CodeIgniter instance + $routing = config('routing'); + $routing->useControllerAttributes = false; + Factories::injectMock('config', 'routing', $routing); + + // Register the test filter (even though attributes are disabled, + // we need it registered to avoid FilterException) + $filterConfig = config('Filters'); + $filterConfig->aliases['testAttributeFilter'] = TestAttributeFilter::class; + service('filters', $filterConfig); + + $routes = service('routes'); + $routes->setAutoRoute(false); + + // We're testing that a route defined normally will work, + // but the attributes on the controller method won't be processed + $routes->get('attribute/filtered', '\Tests\Support\Router\Controllers\AttributeController::filtered'); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + $config = new App(); + $codeigniter = new MockCodeIgniter($config); + + ob_start(); + $codeigniter->run($routes); + $output = ob_get_clean(); + + // When useRouteAttributes is false, the filter attributes should NOT be processed + // So the filter should not have run + $this->assertStringNotContainsString('before_filter_ran', (string) $output); + $this->assertStringNotContainsString('after_filter_ran', (string) $output); + // But the controller method should still execute + $this->assertStringContainsString('Filtered', (string) $output); + } } diff --git a/tests/system/Router/Attributes/CacheTest.php b/tests/system/Router/Attributes/CacheTest.php new file mode 100644 index 000000000000..2eb114c9cd87 --- /dev/null +++ b/tests/system/Router/Attributes/CacheTest.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Services; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class CacheTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + // Clear cache before each test + cache()->clean(); + } + + public function testConstructorDefaults(): void + { + $cache = new Cache(); + + $this->assertSame(3600, $cache->for); + $this->assertNull($cache->key); + } + + public function testConstructorCustomValues(): void + { + $cache = new Cache(for: 300, key: 'custom_key'); + + $this->assertSame(300, $cache->for); + $this->assertSame('custom_key', $cache->key); + } + + public function testBeforeReturnsNullForNonGetRequest(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('POST', '/test'); + + $result = $cache->before($request); + + $this->assertNull($result); + } + + public function testBeforeReturnsCachedResponseWhenFound(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('GET', '/test'); + + // Manually cache a response + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + $cachedData = [ + 'body' => 'Cached content', + 'status' => 200, + 'headers' => ['Content-Type' => 'text/html'], + 'timestamp' => time() - 10, + ]; + cache()->save($cacheKey, $cachedData, 3600); + + $result = $cache->before($request); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Cached content', $result->getBody()); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('text/html', $result->getHeaderLine('Content-Type')); + $this->assertSame('10', $result->getHeaderLine('Age')); + } + + public function testBeforeReturnsNullForInvalidCacheData(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('GET', '/test'); + + // Cache invalid data + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + cache()->save($cacheKey, 'invalid data', 3600); + + $result = $cache->before($request); + + $this->assertNull($result); + } + + public function testBeforeReturnsNullForIncompleteCacheData(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('GET', '/test'); + + // Cache incomplete data (missing 'headers' key) + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + cache()->save($cacheKey, ['body' => 'test', 'status' => 200], 3600); + + $result = $cache->before($request); + + $this->assertNull($result); + } + + public function testBeforeUsesCustomCacheKey(): void + { + $cache = new Cache(key: 'my_custom_key'); + $request = $this->createMockRequest('GET', '/test'); + + // Cache with custom key + $cachedData = [ + 'body' => 'Custom cached content', + 'status' => 200, + 'headers' => [], + 'timestamp' => time(), + ]; + cache()->save('my_custom_key', $cachedData, 3600); + + $result = $cache->before($request); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Custom cached content', $result->getBody()); + } + + public function testAfterReturnsNullForNonGetRequest(): void + { + $cache = new Cache(); + $request = $this->createMockRequest('POST', '/test'); + $response = Services::response(); + + $result = $cache->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testAfterCachesGetRequestResponse(): void + { + $cache = new Cache(for: 300); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + $response->setBody('Test content'); + $response->setStatusCode(200); + $response->setHeader('Content-Type', 'text/plain'); + + $result = $cache->after($request, $response); + + $this->assertSame($response, $result); + + // Verify cache was saved + $cacheKey = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request); + $cached = cache($cacheKey); + + $this->assertIsArray($cached); + $this->assertSame('Test content', $cached['body']); + $this->assertSame(200, $cached['status']); + $this->assertArrayHasKey('timestamp', $cached); + } + + public function testAfterUsesCustomCacheKey(): void + { + $cache = new Cache(key: 'another_custom_key'); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + $response->setBody('Custom key content'); + + $cache->after($request, $response); + + // Verify cache was saved with custom key + $cached = cache('another_custom_key'); + + $this->assertIsArray($cached); + $this->assertSame('Custom key content', $cached['body']); + } + + public function testGenerateCacheKeyIncludesMethodPathAndQuery(): void + { + $cache = new Cache(); + $request1 = $this->createMockRequest('GET', '/test', 'foo=bar'); + $request2 = $this->createMockRequest('GET', '/test', 'foo=baz'); + + $key1 = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request1); + $key2 = $this->getPrivateMethodInvoker($cache, 'generateCacheKey')($request2); + + $this->assertNotSame($key1, $key2); + $this->assertStringStartsWith('route_cache_', $key1); + } + + private function createMockRequest(string $method, string $path, string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + $uri = new SiteURI($config, 'http://example.com' . $path . ($query !== '' ? '?' . $query : '')); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + $request->setMethod($method); + + return $request; + } +} diff --git a/tests/system/Router/Attributes/FilterTest.php b/tests/system/Router/Attributes/FilterTest.php new file mode 100644 index 000000000000..8a230c805287 --- /dev/null +++ b/tests/system/Router/Attributes/FilterTest.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Services; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class FilterTest extends CIUnitTestCase +{ + public function testConstructorWithFilterNameOnly(): void + { + $filter = new Filter(by: 'auth'); + + $this->assertSame('auth', $filter->by); + $this->assertSame([], $filter->having); + } + + public function testConstructorWithFilterNameAndArguments(): void + { + $filter = new Filter(by: 'auth', having: ['admin', 'editor']); + + $this->assertSame('auth', $filter->by); + $this->assertSame(['admin', 'editor'], $filter->having); + } + + public function testConstructorWithEmptyHaving(): void + { + $filter = new Filter(by: 'throttle', having: []); + + $this->assertSame('throttle', $filter->by); + $this->assertSame([], $filter->having); + } + + public function testBeforeReturnsNull(): void + { + $filter = new Filter(by: 'auth'); + $request = $this->createMockRequest('GET', '/test'); + + $result = $filter->before($request); + + $this->assertNull($result); + } + + public function testAfterReturnsNull(): void + { + $filter = new Filter(by: 'toolbar'); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + + $result = $filter->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testGetFiltersReturnsArrayWithFilterNameOnly(): void + { + $filter = new Filter(by: 'csrf'); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertSame('csrf', $filters[0]); + } + + public function testGetFiltersReturnsArrayWithFilterNameAndArguments(): void + { + $filter = new Filter(by: 'auth', having: ['admin']); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertArrayHasKey('auth', $filters); + $this->assertSame(['admin'], $filters['auth']); + } + + public function testGetFiltersReturnsArrayWithMultipleArguments(): void + { + $filter = new Filter(by: 'permission', having: ['posts.edit', 'posts.delete']); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertArrayHasKey('permission', $filters); + $this->assertSame(['posts.edit', 'posts.delete'], $filters['permission']); + } + + public function testGetFiltersWithEmptyHavingReturnsSimpleArray(): void + { + $filter = new Filter(by: 'cors', having: []); + + $filters = $filter->getFilters(); + + $this->assertCount(1, $filters); + $this->assertSame('cors', $filters[0]); + } + + public function testMultipleFiltersCanBeCreated(): void + { + $filter1 = new Filter(by: 'auth'); + $filter2 = new Filter(by: 'csrf'); + $filter3 = new Filter(by: 'throttle', having: ['60', '1']); + + $this->assertSame('auth', $filter1->by); + $this->assertSame('csrf', $filter2->by); + $this->assertSame('throttle', $filter3->by); + $this->assertSame(['60', '1'], $filter3->having); + } + + public function testGetFiltersFormatIsConsistentAcrossInstances(): void + { + $filterWithoutArgs = new Filter(by: 'filter1'); + $filterWithArgs = new Filter(by: 'filter2', having: ['arg1']); + + $filters1 = $filterWithoutArgs->getFilters(); + $filters2 = $filterWithArgs->getFilters(); + + // Without args: simple array + $this->assertArrayNotHasKey('filter1', $filters1); + $this->assertContains('filter1', $filters1); + + // With args: associative array + $this->assertArrayHasKey('filter2', $filters2); + $this->assertIsArray($filters2['filter2']); + } + + public function testFilterWithNumericArguments(): void + { + $filter = new Filter(by: 'rate_limit', having: [100, 60]); + + $filters = $filter->getFilters(); + + $this->assertArrayHasKey('rate_limit', $filters); + $this->assertSame([100, 60], $filters['rate_limit']); + } + + public function testFilterWithMixedTypeArguments(): void + { + $filter = new Filter(by: 'custom', having: ['string', 123, true]); + + $filters = $filter->getFilters(); + + $this->assertArrayHasKey('custom', $filters); + $this->assertSame(['string', 123, true], $filters['custom']); + } + + public function testFilterWithAssociativeArrayArguments(): void + { + $filter = new Filter(by: 'configured', having: ['option1' => 'value1', 'option2' => 'value2']); + + $filters = $filter->getFilters(); + + $this->assertArrayHasKey('configured', $filters); + $this->assertSame(['option1' => 'value1', 'option2' => 'value2'], $filters['configured']); + } + + public function testBeforeDoesNotModifyRequest(): void + { + $filter = new Filter(by: 'auth', having: ['admin']); + $request = $this->createMockRequest('POST', '/admin/users'); + + $originalMethod = $request->getMethod(); + $originalPath = $request->getUri()->getPath(); + + $result = $filter->before($request); + + $this->assertNull($result); + $this->assertSame($originalMethod, $request->getMethod()); + $this->assertSame($originalPath, $request->getUri()->getPath()); + } + + public function testAfterDoesNotModifyResponse(): void + { + $filter = new Filter(by: 'toolbar'); + $request = $this->createMockRequest('GET', '/test'); + $response = Services::response(); + $response->setBody('Test content'); + $response->setStatusCode(200); + + $result = $filter->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Test content', $response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + } + + private function createMockRequest(string $method, string $path, string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + $uri = new SiteURI($config, 'http://example.com' . $path . ($query !== '' ? '?' . $query : '')); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + $request->setMethod($method); + + return $request; + } +} diff --git a/tests/system/Router/Attributes/RestrictTest.php b/tests/system/Router/Attributes/RestrictTest.php new file mode 100644 index 000000000000..556884d51c77 --- /dev/null +++ b/tests/system/Router/Attributes/RestrictTest.php @@ -0,0 +1,464 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Attributes; + +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use Config\Services; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class RestrictTest extends CIUnitTestCase +{ + public function testConstructorWithNoRestrictions(): void + { + $restrict = new Restrict(); + + $this->assertNull($restrict->environment); + $this->assertNull($restrict->hostname); + $this->assertNull($restrict->subdomain); + } + + public function testConstructorWithEnvironmentOnly(): void + { + $restrict = new Restrict(environment: 'production'); + + $this->assertSame('production', $restrict->environment); + $this->assertNull($restrict->hostname); + $this->assertNull($restrict->subdomain); + } + + public function testConstructorWithHostnameOnly(): void + { + $restrict = new Restrict(hostname: 'example.com'); + + $this->assertNull($restrict->environment); + $this->assertSame('example.com', $restrict->hostname); + $this->assertNull($restrict->subdomain); + } + + public function testConstructorWithSubdomainOnly(): void + { + $restrict = new Restrict(subdomain: 'api'); + + $this->assertNull($restrict->environment); + $this->assertNull($restrict->hostname); + $this->assertSame('api', $restrict->subdomain); + } + + public function testConstructorWithMultipleRestrictions(): void + { + $restrict = new Restrict( + environment: ['production', 'staging'], + hostname: ['example.com', 'test.com'], + subdomain: ['api', 'admin'], + ); + + $this->assertSame(['production', 'staging'], $restrict->environment); + $this->assertSame(['example.com', 'test.com'], $restrict->hostname); + $this->assertSame(['api', 'admin'], $restrict->subdomain); + } + + public function testBeforeReturnsNullWhenNoRestrictionsSet(): void + { + $restrict = new Restrict(); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testAfterReturnsNull(): void + { + $restrict = new Restrict(environment: 'testing'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + $response = Services::response(); + + $result = $restrict->after($request, $response); + + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testCheckEnvironmentAllowsCurrentEnvironment(): void + { + $restrict = new Restrict(environment: ENVIRONMENT); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckEnvironmentAllowsFromArray(): void + { + $restrict = new Restrict(environment: ['development', 'testing']); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckEnvironmentThrowsWhenNotAllowed(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is not allowed.'); + + $restrict = new Restrict(environment: 'production'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentThrowsWhenExplicitlyDenied(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is blocked.'); + + $currentEnv = ENVIRONMENT; + $restrict = new Restrict(environment: ['!' . $currentEnv]); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentDenialTakesPrecedence(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is blocked.'); + + $currentEnv = ENVIRONMENT; + // Include current env in allowed list but also deny it + $restrict = new Restrict(environment: [$currentEnv, '!' . $currentEnv]); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentAllowsWithOnlyDenials(): void + { + // Only deny production, allow everything else + $restrict = new Restrict(environment: ['!production', '!staging']); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckEnvironmentWithEmptyArray(): void + { + $restrict = new Restrict(environment: []); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameAllowsSingleHost(): void + { + $restrict = new Restrict(hostname: 'example.com'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameAllowsFromArray(): void + { + $restrict = new Restrict(hostname: ['example.com', 'test.com']); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameThrowsWhenNotAllowed(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Host is not allowed.'); + + $restrict = new Restrict(hostname: 'allowed.com'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckHostnameIsCaseInsensitive(): void + { + $restrict = new Restrict(hostname: 'EXAMPLE.COM'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckHostnameWithEmptyArray(): void + { + $restrict = new Restrict(hostname: []); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainAllowsSingleSubdomain(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainAllowsFromArray(): void + { + $restrict = new Restrict(subdomain: ['api', 'admin']); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainThrowsWhenNotAllowed(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: subdomain is blocked.'); + + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'admin.example.com'); + + $restrict->before($request); + } + + public function testCheckSubdomainThrowsWhenNoSubdomainExists(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckSubdomainIsCaseInsensitive(): void + { + $restrict = new Restrict(subdomain: 'API'); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testCheckSubdomainWithEmptyArray(): void + { + $restrict = new Restrict(subdomain: []); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainReturnsEmptyForLocalhost(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'localhost'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainReturnsEmptyForIPAddress(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', '192.168.1.1'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainReturnsEmptyForTwoPartDomain(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainHandlesSingleSubdomain(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainHandlesMultipleSubdomains(): void + { + $restrict = new Restrict(subdomain: 'admin.api'); + $request = $this->createMockRequest('GET', '/test', 'admin.api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainHandlesTwoPartTLD(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'api.example.co.uk'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testGetSubdomainReturnsEmptyForDomainWithTwoPartTLD(): void + { + $restrict = new Restrict(subdomain: 'api'); + $request = $this->createMockRequest('GET', '/test', 'example.co.uk'); + + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Subdomain required'); + + $restrict->before($request); + } + + public function testGetSubdomainHandlesMultipleSubdomainsWithTwoPartTLD(): void + { + $restrict = new Restrict(subdomain: 'admin.api'); + $request = $this->createMockRequest('GET', '/test', 'admin.api.example.co.uk'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testMultipleRestrictionsAllMustPass(): void + { + $restrict = new Restrict( + environment: ENVIRONMENT, + hostname: ['api.example.com', 'example.com'], + subdomain: ['api'], + ); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $result = $restrict->before($request); + + $this->assertNull($result); + } + + public function testMultipleRestrictionsFailIfAnyFails(): void + { + $this->expectException(PageNotFoundException::class); + + $restrict = new Restrict( + environment: ENVIRONMENT, // Passes + hostname: 'api.example.com', // Passes + subdomain: 'admin', // Fails + ); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $restrict->before($request); + } + + public function testCheckEnvironmentFailsFirst(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Current environment is not allowed.'); + + $restrict = new Restrict( + environment: 'production', // Fails + hostname: 'example.com', // Would pass + ); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckHostnameFailsSecond(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: Host is not allowed.'); + + $restrict = new Restrict( + environment: ENVIRONMENT, // Passes + hostname: 'allowed.com', // Fails + subdomain: 'api', // Would fail + ); + $request = $this->createMockRequest('GET', '/test', 'example.com'); + + $restrict->before($request); + } + + public function testCheckSubdomainFailsLast(): void + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage('Access denied: subdomain is blocked.'); + + $restrict = new Restrict( + environment: ENVIRONMENT, // Passes + hostname: 'api.example.com', // Passes + subdomain: 'admin', // Fails + ); + $request = $this->createMockRequest('GET', '/test', 'api.example.com'); + + $restrict->before($request); + } + + private function createMockRequest(string $method, string $path, string $host = 'example.com', string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + // Use the host parameter to properly set the host in SiteURI + $uri = new SiteURI($config, $path . ($query !== '' ? '?' . $query : ''), $host, 'http'); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + $request->setMethod($method); + + return $request; + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 9399095c1d44..3438e832d55c 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -116,6 +116,8 @@ Helpers and Functions Others ====== +- **Controller Attributes:** Added support for PHP Attributes to define filters and other metadata on controller classes and methods. See :ref:`Controller Attributes ` for details. + *************** Message Changes *************** diff --git a/user_guide_src/source/incoming/controller_attributes.rst b/user_guide_src/source/incoming/controller_attributes.rst new file mode 100644 index 000000000000..10e93c37cddf --- /dev/null +++ b/user_guide_src/source/incoming/controller_attributes.rst @@ -0,0 +1,71 @@ +.. _incoming/controller_attributes: + +##################### +Controller Attributes +##################### + +PHP Attributes can be used to define filters and other metadata on controller classes and methods. This keeps the configuration close to the code it affects, and can make it easier to see at a glance what filters are applied to a given controller or method. This works across all routing methods, including auto-routing, which allows for a near feature-parity between the more robust route declarations and auto-routing. + +.. contents:: + :local: + :depth: 2 + +Getting Started +*************** + +Controller Attributes can be applied to either the entire class, or to a specific method. The following example shows how to apply the ``Filters`` attribute to a controller class: + +.. literalinclude:: controller_attributes/001.php + +In this example, the ``Auth`` filter will be applied to all methods in ``AdminController``. + +You can also apply the ``Filters`` attribute to a specific method within a controller. This allows you to apply filters only to certain methods, while leaving others unaffected. Here's an example: + +.. literalinclude:: controller_attributes/002.php + +Class-level and method-level attributes can work together to provide a flexible way to manage your routes at the controller level. + +Disabling Attributes +-------------------- + +If you know that you will not be using attributes in your application, you can disable the feature by setting the ``$useControllerAttributes`` property in your ``app/Config/Routing.php`` file to ``false``. + +Provided Attributes +******************* + +Filter +------- + +The ``Filters`` attribute allows you to specify one or more filters to be applied to a controller class or method. You can specify filters to run before or after the controller action, and you can also provide parameters to the filters. Here's an example of how to use the ``Filters`` attribute: + +.. literalinclude:: controller_attributes/003.php + +.. note:: + + When filters are applied both by an attribute and in the filter configuration file, they will both be applied, but that could lead to unexpected results. + +Restrict +-------- + +The ``Restrict`` attribute allows you to restrict access to the class or method based on the domain, the sub-domain, or +the environment the application is running in. Here's an exmaple of how to use the ``Restrict`` attribute: + +.. literalinclude:: controller_attributes/004.php + +Cache +----- + +The ``Cache`` attribute allows the output of the controller method to be cached for a specified amount of time. You can specify a duration in seconds, and optionally a cache key. Here's an example of how to use the ``Cache`` attribute: + +.. literalinclude:: controller_attributes/005.php + +Custom Attributes +***************** + +You can also create your own custom attributes to add metadata or behavior to your controllers and methods. Custom attributes must implement the ``CodeIgniter\Router\Attributes\RouteAttributeInterface`` interface. Here's an example of a custom attribute that adds a custom header to the response: + +.. literalinclude:: controller_attributes/006.php + +You can then apply this custom attribute to a controller class or method just like the built-in attributes: + +.. literalinclude:: controller_attributes/007.php diff --git a/user_guide_src/source/incoming/controller_attributes/001.php b/user_guide_src/source/incoming/controller_attributes/001.php new file mode 100644 index 000000000000..6b1bb4957d0d --- /dev/null +++ b/user_guide_src/source/incoming/controller_attributes/001.php @@ -0,0 +1,14 @@ +setHeader($this->name, $this->value); + + return $response; + } +} diff --git a/user_guide_src/source/incoming/controller_attributes/007.php b/user_guide_src/source/incoming/controller_attributes/007.php new file mode 100644 index 000000000000..07386c1c16d0 --- /dev/null +++ b/user_guide_src/source/incoming/controller_attributes/007.php @@ -0,0 +1,52 @@ +response->setJSON([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + } + + /** + * Add multiple custom headers using the IS_REPEATABLE attribute option. + * Each AddHeader attribute will be executed in order. + */ + #[AddHeader('X-API-Version', '2.0')] + #[AddHeader('X-Rate-Limit', '100')] + #[AddHeader('X-Content-Source', 'cache')] + public function statistics() + { + return $this->response->setJSON([ + 'users' => 1500, + 'posts' => 3200, + ]); + } + + /** + * Combine custom attributes with built-in attributes. + * The Cache attribute will cache the response, + * and AddHeader will add the custom header. + */ + #[AddHeader('X-Powered-By', 'My Custom API')] + #[Cache(for: 3600)] + public function dashboard() + { + return $this->response->setJSON([ + 'status' => 'operational', + 'uptime' => '99.9%', + ]); + } +} diff --git a/user_guide_src/source/incoming/index.rst b/user_guide_src/source/incoming/index.rst index d8faf0a869b5..ebcc316e10ee 100644 --- a/user_guide_src/source/incoming/index.rst +++ b/user_guide_src/source/incoming/index.rst @@ -10,6 +10,7 @@ Controllers handle incoming requests. routing controllers filters + controller_attributes auto_routing_improved message request diff --git a/utils/phpstan-baseline/assign.propertyType.neon b/utils/phpstan-baseline/assign.propertyType.neon index 813993f45e10..fa10aa1f575f 100644 --- a/utils/phpstan-baseline/assign.propertyType.neon +++ b/utils/phpstan-baseline/assign.propertyType.neon @@ -1,7 +1,12 @@ -# total 28 errors +# total 29 errors parameters: ignoreErrors: + - + message: '#^Property CodeIgniter\\CodeIgniter\:\:\$request \(CodeIgniter\\HTTP\\CLIRequest\|CodeIgniter\\HTTP\\IncomingRequest\|null\) does not accept CodeIgniter\\HTTP\\RequestInterface\.$#' + count: 1 + path: ../../system/CodeIgniter.php + - message: '#^Property CodeIgniter\\Controller\:\:\$request \(CodeIgniter\\HTTP\\CLIRequest\|CodeIgniter\\HTTP\\IncomingRequest\) does not accept CodeIgniter\\HTTP\\RequestInterface\.$#' count: 1 diff --git a/utils/phpstan-baseline/greaterOrEqual.alwaysTrue.neon b/utils/phpstan-baseline/greaterOrEqual.alwaysTrue.neon new file mode 100644 index 000000000000..a6c8edb13d11 --- /dev/null +++ b/utils/phpstan-baseline/greaterOrEqual.alwaysTrue.neon @@ -0,0 +1,4 @@ +# total 0 errors + +parameters: + ignoreErrors: [] diff --git a/utils/phpstan-baseline/isset.property.neon b/utils/phpstan-baseline/isset.property.neon new file mode 100644 index 000000000000..8af257e29e83 --- /dev/null +++ b/utils/phpstan-baseline/isset.property.neon @@ -0,0 +1,43 @@ +# total 8 errors + +parameters: + ignoreErrors: + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$description \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/CLI/BaseCommand.php + + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$usage \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/CLI/BaseCommand.php + + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$group \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/CLI/Commands.php + + - + message: '#^Property CodeIgniter\\Database\\BaseConnection\\:\:\$strictOn \(bool\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/Database/MySQLi/Connection.php + + - + message: '#^Property CodeIgniter\\Database\\BaseConnection\\:\:\$password \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../system/Database/SQLite3/Connection.php + + - + message: '#^Property CodeIgniter\\CLI\\BaseCommand\:\:\$group \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../tests/system/Commands/BaseCommandTest.php + + - + message: '#^Property CodeIgniter\\Database\\BaseConnection\\:\:\$charset \(string\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../tests/system/Database/BaseConnectionTest.php + + - + message: '#^Property CodeIgniter\\I18n\\TimeDifference\:\:\$days \(float\|int\) in isset\(\) is not nullable\.$#' + count: 1 + path: ../../tests/system/I18n/TimeDifferenceTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 4a3fe00ddf57..7570aded7a3d 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2795 errors +# total 2815 errors includes: - argument.type.neon @@ -10,6 +10,7 @@ includes: - codeigniter.superglobalAccessAssign.neon - deadCode.unreachable.neon - empty.notAllowed.neon + - isset.property.neon - method.alreadyNarrowedType.neon - method.childParameterType.neon - method.childReturnType.neon @@ -24,6 +25,7 @@ includes: - property.nonObject.neon - property.notFound.neon - property.phpDocType.neon + - return.type.neon - staticMethod.notFound.neon - ternary.shortNotAllowed.neon - varTag.type.neon diff --git a/utils/phpstan-baseline/method.alreadyNarrowedType.neon b/utils/phpstan-baseline/method.alreadyNarrowedType.neon index f3c373da1f4a..1e4c261c6d5d 100644 --- a/utils/phpstan-baseline/method.alreadyNarrowedType.neon +++ b/utils/phpstan-baseline/method.alreadyNarrowedType.neon @@ -1,4 +1,4 @@ -# total 22 errors +# total 24 errors parameters: ignoreErrors: @@ -22,6 +22,11 @@ parameters: count: 1 path: ../../tests/system/CodeIgniterTest.php + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/Commands/BaseCommandTest.php + - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsBool\(\) with bool will always evaluate to true\.$#' count: 1 @@ -32,6 +37,11 @@ parameters: count: 2 path: ../../tests/system/Config/FactoriesTest.php + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/Database/BaseConnectionTest.php + - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' count: 2 diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 9bb3a84ef6c0..9df65635283e 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1386 errors +# total 1391 errors parameters: ignoreErrors: @@ -4212,6 +4212,31 @@ parameters: count: 1 path: ../../system/Publisher/Publisher.php + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Filter\:\:__construct\(\) has parameter \$having with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Filter.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Filter\:\:getFilters\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Filter.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Restrict\:\:__construct\(\) has parameter \$environment with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Restrict.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Restrict\:\:__construct\(\) has parameter \$hostname with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Restrict.php + + - + message: '#^Method CodeIgniter\\Router\\Attributes\\Restrict\:\:__construct\(\) has parameter \$subdomain with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Router/Attributes/Restrict.php + - message: '#^Method CodeIgniter\\Router\\AutoRouter\:\:getRoute\(\) return type has no value type specified in iterable type array\.$#' count: 1 diff --git a/utils/phpstan-baseline/nullCoalesce.property.neon b/utils/phpstan-baseline/nullCoalesce.property.neon index a5889293f5fa..e473da4a4a52 100644 --- a/utils/phpstan-baseline/nullCoalesce.property.neon +++ b/utils/phpstan-baseline/nullCoalesce.property.neon @@ -1,4 +1,4 @@ -# total 8 errors +# total 11 errors parameters: ignoreErrors: @@ -27,6 +27,21 @@ parameters: count: 1 path: ../../system/Throttle/Throttler.php + - + message: '#^Property CodeIgniter\\HomeTest\:\:\$session \(array\\) on left side of \?\? is not nullable\.$#' + count: 1 + path: ../../tests/system/HomeTest.php + + - + message: '#^Property CodeIgniter\\Test\\FeatureTestAutoRoutingImprovedTest\:\:\$session \(array\\) on left side of \?\? is not nullable\.$#' + count: 1 + path: ../../tests/system/Test/FeatureTestAutoRoutingImprovedTest.php + + - + message: '#^Property CodeIgniter\\Test\\FeatureTestTraitTest\:\:\$session \(array\\) on left side of \?\? is not nullable\.$#' + count: 1 + path: ../../tests/system/Test/FeatureTestTraitTest.php + - message: '#^Property CodeIgniter\\Test\\FilterTestTraitTest\:\:\$request \(CodeIgniter\\HTTP\\RequestInterface\) on left side of \?\?\= is not nullable\.$#' count: 1 diff --git a/utils/phpstan-baseline/return.type.neon b/utils/phpstan-baseline/return.type.neon new file mode 100644 index 000000000000..40b214c3fce9 --- /dev/null +++ b/utils/phpstan-baseline/return.type.neon @@ -0,0 +1,8 @@ +# total 1 error + +parameters: + ignoreErrors: + - + message: '#^Method CodeIgniter\\Router\\Router\:\:getRouteAttributes\(\) should return array\{class\: list\, method\: list\\} but returns array\{class\: list\, method\: list\\}\.$#' + count: 1 + path: ../../system/Router/Router.php From 37ff9faf74897f412401a7cf10cb49f8e5693cfd Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 10 Oct 2025 07:38:35 +0200 Subject: [PATCH 32/84] feat: add enum casting (#9752) * feat: support casting values to enums fix phpstan errors more validation and testing feat: support casting values to enums * apply code suggestions * apply code suggestions --- system/DataCaster/Cast/CastInterface.php | 12 +- system/DataCaster/Cast/EnumCast.php | 120 ++++++++ system/DataCaster/DataCaster.php | 2 + system/Entity/Cast/BaseCast.php | 16 -- system/Entity/Cast/CastInterface.php | 4 +- system/Entity/Cast/EnumCast.php | 131 +++++++++ system/Entity/Entity.php | 2 + system/Entity/Exceptions/CastException.php | 50 ++++ system/Language/en/Cast.php | 5 + tests/_support/Enum/ColorEnum.php | 21 ++ tests/_support/Enum/RoleEnum.php | 21 ++ tests/_support/Enum/StatusEnum.php | 21 ++ .../DataConverter/DataConverterTest.php | 256 ++++++++++++++++++ tests/system/Entity/EntityTest.php | 202 ++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/models/entities.rst | 31 ++- user_guide_src/source/models/entities/024.php | 10 + user_guide_src/source/models/entities/025.php | 12 + user_guide_src/source/models/entities/026.php | 17 ++ user_guide_src/source/models/entities/027.php | 12 + user_guide_src/source/models/model.rst | 15 + utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 102 +------ .../staticMethod.notFound.neon | 12 +- 24 files changed, 954 insertions(+), 123 deletions(-) create mode 100644 system/DataCaster/Cast/EnumCast.php create mode 100644 system/Entity/Cast/EnumCast.php create mode 100644 tests/_support/Enum/ColorEnum.php create mode 100644 tests/_support/Enum/RoleEnum.php create mode 100644 tests/_support/Enum/StatusEnum.php create mode 100644 user_guide_src/source/models/entities/024.php create mode 100644 user_guide_src/source/models/entities/025.php create mode 100644 user_guide_src/source/models/entities/026.php create mode 100644 user_guide_src/source/models/entities/027.php diff --git a/system/DataCaster/Cast/CastInterface.php b/system/DataCaster/Cast/CastInterface.php index f90f2b227e1d..7f5b05102a7d 100644 --- a/system/DataCaster/Cast/CastInterface.php +++ b/system/DataCaster/Cast/CastInterface.php @@ -18,9 +18,9 @@ interface CastInterface /** * Takes a value from DataSource, returns its value for PHP. * - * @param mixed $value Data from database driver - * @param list $params Additional param - * @param object|null $helper Helper object. E.g., database connection + * @param mixed $value Data from database driver + * @param array $params Additional param + * @param object|null $helper Helper object. E.g., database connection * * @return mixed PHP native value */ @@ -33,9 +33,9 @@ public static function get( /** * Takes a PHP value, returns its value for DataSource. * - * @param mixed $value PHP native value - * @param list $params Additional param - * @param object|null $helper Helper object. E.g., database connection + * @param mixed $value PHP native value + * @param array $params Additional param + * @param object|null $helper Helper object. E.g., database connection * * @return mixed Data to pass to database driver */ diff --git a/system/DataCaster/Cast/EnumCast.php b/system/DataCaster/Cast/EnumCast.php new file mode 100644 index 000000000000..454251dcba6b --- /dev/null +++ b/system/DataCaster/Cast/EnumCast.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\DataCaster\Cast; + +use BackedEnum; +use CodeIgniter\DataCaster\Exceptions\CastException; +use ReflectionEnum; +use UnitEnum; + +/** + * Class EnumCast + * + * Handles casting for PHP enums (both backed and unit enums) + * + * (PHP) [enum --> value/name] --> (DB driver) --> (DB column) int|string + * [ <-- value/name] <-- (DB driver) <-- (DB column) int|string + */ +class EnumCast extends BaseCast implements CastInterface +{ + public static function get( + mixed $value, + array $params = [], + ?object $helper = null, + ): BackedEnum|UnitEnum { + if (! is_string($value) && ! is_int($value)) { + self::invalidTypeValueError($value); + } + + $enumClass = $params[0] ?? null; + + if ($enumClass === null) { + throw CastException::forMissingEnumClass(); + } + + if (! enum_exists($enumClass)) { + throw CastException::forNotEnum($enumClass); + } + + $reflection = new ReflectionEnum($enumClass); + + // Unit enum + if (! $reflection->isBacked()) { + // Unit enum - match by name + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + + throw CastException::forInvalidEnumCaseName($enumClass, $value); + } + + // Backed enum - validate and cast the value to proper type + $backingType = $reflection->getBackingType(); + + // Cast to proper type (int or string) + if ($backingType->getName() === 'int') { + $value = (int) $value; + } elseif ($backingType->getName() === 'string') { + $value = (string) $value; + } + + $enum = $enumClass::tryFrom($value); + + if ($enum === null) { + throw CastException::forInvalidEnumValue($enumClass, $value); + } + + return $enum; + } + + public static function set( + mixed $value, + array $params = [], + ?object $helper = null, + ): int|string { + if (! is_object($value) || ! enum_exists($value::class)) { + self::invalidTypeValueError($value); + } + + // Get the expected enum class + $enumClass = $params[0] ?? null; + + if ($enumClass === null) { + throw CastException::forMissingEnumClass(); + } + + if (! enum_exists($enumClass)) { + throw CastException::forNotEnum($enumClass); + } + + // Validate that the enum is of the expected type + if (! $value instanceof $enumClass) { + throw CastException::forInvalidEnumType($enumClass, $value::class); + } + + $reflection = new ReflectionEnum($value::class); + + // Backed enum - return the properly typed backing value + if ($reflection->isBacked()) { + /** @var BackedEnum $value */ + return $value->value; + } + + // Unit enum - return the case name + /** @var UnitEnum $value */ + return $value->name; + } +} diff --git a/system/DataCaster/DataCaster.php b/system/DataCaster/DataCaster.php index 81ebe233c125..3f2604ddc902 100644 --- a/system/DataCaster/DataCaster.php +++ b/system/DataCaster/DataCaster.php @@ -18,6 +18,7 @@ use CodeIgniter\DataCaster\Cast\CastInterface; use CodeIgniter\DataCaster\Cast\CSVCast; use CodeIgniter\DataCaster\Cast\DatetimeCast; +use CodeIgniter\DataCaster\Cast\EnumCast; use CodeIgniter\DataCaster\Cast\FloatCast; use CodeIgniter\DataCaster\Cast\IntBoolCast; use CodeIgniter\DataCaster\Cast\IntegerCast; @@ -48,6 +49,7 @@ final class DataCaster 'boolean' => BooleanCast::class, 'csv' => CSVCast::class, 'datetime' => DatetimeCast::class, + 'enum' => EnumCast::class, 'double' => FloatCast::class, 'float' => FloatCast::class, 'int' => IntegerCast::class, diff --git a/system/Entity/Cast/BaseCast.php b/system/Entity/Cast/BaseCast.php index 34cb28e787c5..41a19e570d9b 100644 --- a/system/Entity/Cast/BaseCast.php +++ b/system/Entity/Cast/BaseCast.php @@ -18,27 +18,11 @@ */ abstract class BaseCast implements CastInterface { - /** - * Get - * - * @param array|bool|float|int|object|string|null $value Data - * @param array $params Additional param - * - * @return array|bool|float|int|object|string|null - */ public static function get($value, array $params = []) { return $value; } - /** - * Set - * - * @param array|bool|float|int|object|string|null $value Data - * @param array $params Additional param - * - * @return array|bool|float|int|object|string|null - */ public static function set($value, array $params = []) { return $value; diff --git a/system/Entity/Cast/CastInterface.php b/system/Entity/Cast/CastInterface.php index 9d790e8edbb9..d2c6950ee927 100644 --- a/system/Entity/Cast/CastInterface.php +++ b/system/Entity/Cast/CastInterface.php @@ -26,7 +26,7 @@ interface CastInterface * Takes a raw value from Entity, returns its value for PHP. * * @param array|bool|float|int|object|string|null $value Data - * @param array $params Additional param + * @param array $params Additional param * * @return array|bool|float|int|object|string|null */ @@ -36,7 +36,7 @@ public static function get($value, array $params = []); * Takes a PHP value, returns its raw value for Entity. * * @param array|bool|float|int|object|string|null $value Data - * @param array $params Additional param + * @param array $params Additional param * * @return array|bool|float|int|object|string|null */ diff --git a/system/Entity/Cast/EnumCast.php b/system/Entity/Cast/EnumCast.php new file mode 100644 index 000000000000..7ea82d6f24a5 --- /dev/null +++ b/system/Entity/Cast/EnumCast.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use BackedEnum; +use CodeIgniter\Entity\Exceptions\CastException; +use ReflectionEnum; +use UnitEnum; + +class EnumCast extends BaseCast +{ + public static function get($value, array $params = []): BackedEnum|UnitEnum + { + $enumClass = $params[0] ?? null; + + if ($enumClass === null) { + throw CastException::forMissingEnumClass(); + } + + if (! enum_exists($enumClass)) { + throw CastException::forNotEnum($enumClass); + } + + $reflection = new ReflectionEnum($enumClass); + + // Backed enum - validate and cast the value to proper type + if ($reflection->isBacked()) { + $backingType = $reflection->getBackingType(); + + // Cast to proper type (int or string) + if ($backingType->getName() === 'int') { + $value = (int) $value; + } elseif ($backingType->getName() === 'string') { + $value = (string) $value; + } + + $enum = $enumClass::tryFrom($value); + + if ($enum === null) { + throw CastException::forInvalidEnumValue($enumClass, $value); + } + + return $enum; + } + + // Unit enum - match by name + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + + throw CastException::forInvalidEnumCaseName($enumClass, $value); + } + + public static function set($value, array $params = []): int|string + { + // Get the expected enum class + $enumClass = $params[0] ?? null; + + if ($enumClass === null) { + throw CastException::forMissingEnumClass(); + } + + if (! enum_exists($enumClass)) { + throw CastException::forNotEnum($enumClass); + } + + // If it's already an enum object, validate and extract its value + if (is_object($value) && enum_exists($value::class)) { + // Validate that the enum is of the expected type + if (! $value instanceof $enumClass) { + throw CastException::forInvalidEnumType($enumClass, $value::class); + } + + $reflection = new ReflectionEnum($value::class); + + // Backed enum - return the properly typed backing value + if ($reflection->isBacked()) { + /** @var BackedEnum $value */ + return $value->value; + } + + // Unit enum - return the case name + /** @var UnitEnum $value */ + return $value->name; + } + + $reflection = new ReflectionEnum($enumClass); + + // Validate backed enum values + if ($reflection->isBacked()) { + $backingType = $reflection->getBackingType(); + + // Cast to proper type (int or string) + if ($backingType->getName() === 'int') { + $value = (int) $value; + } elseif ($backingType->getName() === 'string') { + $value = (string) $value; + } + + if ($enumClass::tryFrom($value) === null) { + throw CastException::forInvalidEnumValue($enumClass, $value); + } + + return $value; + } + + // Validate unit enum case names - must be a string + $value = (string) $value; + + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $value; + } + } + + throw CastException::forInvalidEnumCaseName($enumClass, $value); + } +} diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 0e54c722e354..0cbcdef00904 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -18,6 +18,7 @@ use CodeIgniter\Entity\Cast\BooleanCast; use CodeIgniter\Entity\Cast\CSVCast; use CodeIgniter\Entity\Cast\DatetimeCast; +use CodeIgniter\Entity\Cast\EnumCast; use CodeIgniter\Entity\Cast\FloatCast; use CodeIgniter\Entity\Cast\IntBoolCast; use CodeIgniter\Entity\Cast\IntegerCast; @@ -92,6 +93,7 @@ class Entity implements JsonSerializable 'csv' => CSVCast::class, 'datetime' => DatetimeCast::class, 'double' => FloatCast::class, + 'enum' => EnumCast::class, 'float' => FloatCast::class, 'int' => IntegerCast::class, 'integer' => IntegerCast::class, diff --git a/system/Entity/Exceptions/CastException.php b/system/Entity/Exceptions/CastException.php index 90d3885d9b62..033f1ced478e 100644 --- a/system/Entity/Exceptions/CastException.php +++ b/system/Entity/Exceptions/CastException.php @@ -72,4 +72,54 @@ public static function forInvalidTimestamp() { return new static(lang('Cast.invalidTimestamp')); } + + /** + * Thrown when the enum class is not specified in cast parameters. + * + * @return static + */ + public static function forMissingEnumClass() + { + return new static(lang('Cast.enumMissingClass')); + } + + /** + * Thrown when the specified class is not an enum. + * + * @return static + */ + public static function forNotEnum(string $class) + { + return new static(lang('Cast.enumNotEnum', [$class])); + } + + /** + * Thrown when an invalid value is provided for an enum. + * + * @return static + */ + public static function forInvalidEnumValue(string $enumClass, mixed $value) + { + return new static(lang('Cast.enumInvalidValue', [$enumClass, $value])); + } + + /** + * Thrown when an invalid case name is provided for a unit enum. + * + * @return static + */ + public static function forInvalidEnumCaseName(string $enumClass, string $caseName) + { + return new static(lang('Cast.enumInvalidCaseName', [$caseName, $enumClass])); + } + + /** + * Thrown when an enum instance of wrong type is provided. + * + * @return static + */ + public static function forInvalidEnumType(string $expectedClass, string $actualClass) + { + return new static(lang('Cast.enumInvalidType', [$actualClass, $expectedClass])); + } } diff --git a/system/Language/en/Cast.php b/system/Language/en/Cast.php index 04ff1ef9e11f..63d9fba01b7c 100644 --- a/system/Language/en/Cast.php +++ b/system/Language/en/Cast.php @@ -14,6 +14,11 @@ // Cast language settings return [ 'baseCastMissing' => 'The "{0}" class must inherit the "CodeIgniter\Entity\Cast\BaseCast" class.', + 'enumInvalidCaseName' => 'Invalid case name "{0}" for enum "{1}".', + 'enumInvalidType' => 'Expected enum of type "{1}", but received "{0}".', + 'enumInvalidValue' => 'Invalid value "{1}" for enum "{0}".', + 'enumMissingClass' => 'Enum class must be specified for enum casting.', + 'enumNotEnum' => 'The "{0}" is not a valid enum class.', 'invalidCastMethod' => 'The "{0}" is invalid cast method, valid methods are: ["get", "set"].', 'invalidTimestamp' => 'Type casting "timestamp" expects a correct timestamp.', 'jsonErrorCtrlChar' => 'Unexpected control character found.', diff --git a/tests/_support/Enum/ColorEnum.php b/tests/_support/Enum/ColorEnum.php new file mode 100644 index 000000000000..3e529ffc386c --- /dev/null +++ b/tests/_support/Enum/ColorEnum.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Enum; + +enum ColorEnum +{ + case RED; + case GREEN; + case BLUE; +} diff --git a/tests/_support/Enum/RoleEnum.php b/tests/_support/Enum/RoleEnum.php new file mode 100644 index 000000000000..9984f76451eb --- /dev/null +++ b/tests/_support/Enum/RoleEnum.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Enum; + +enum RoleEnum: int +{ + case GUEST = 0; + case USER = 1; + case ADMIN = 2; +} diff --git a/tests/_support/Enum/StatusEnum.php b/tests/_support/Enum/StatusEnum.php new file mode 100644 index 000000000000..0c02aaaa4704 --- /dev/null +++ b/tests/_support/Enum/StatusEnum.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Enum; + +enum StatusEnum: string +{ + case PENDING = 'pending'; + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} diff --git a/tests/system/DataConverter/DataConverterTest.php b/tests/system/DataConverter/DataConverterTest.php index 10a35e6909fc..220e101ba762 100644 --- a/tests/system/DataConverter/DataConverterTest.php +++ b/tests/system/DataConverter/DataConverterTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\DataConverter; use Closure; +use CodeIgniter\DataCaster\Exceptions\CastException; use CodeIgniter\HTTP\URI; use CodeIgniter\I18n\Time; use CodeIgniter\Test\CIUnitTestCase; @@ -22,6 +23,9 @@ use PHPUnit\Framework\Attributes\Group; use Tests\Support\Entity\CustomUser; use Tests\Support\Entity\User; +use Tests\Support\Enum\ColorEnum; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; /** * @internal @@ -193,6 +197,76 @@ public static function provideConvertDataFromDB(): iterable 'temp' => 15.9, ], ], + 'enum string-backed' => [ + [ + 'id' => 'int', + 'status' => 'enum[' . StatusEnum::class . ']', + ], + [ + 'id' => '1', + 'status' => 'active', + ], + [ + 'id' => 1, + 'status' => StatusEnum::ACTIVE, + ], + ], + 'enum int-backed' => [ + [ + 'id' => 'int', + 'role' => 'enum[' . RoleEnum::class . ']', + ], + [ + 'id' => '1', + 'role' => '2', + ], + [ + 'id' => 1, + 'role' => RoleEnum::ADMIN, + ], + ], + 'enum unit' => [ + [ + 'id' => 'int', + 'color' => 'enum[' . ColorEnum::class . ']', + ], + [ + 'id' => '1', + 'color' => 'RED', + ], + [ + 'id' => 1, + 'color' => ColorEnum::RED, + ], + ], + 'enum nullable null' => [ + [ + 'id' => 'int', + 'status' => '?enum[' . StatusEnum::class . ']', + ], + [ + 'id' => '1', + 'status' => null, + ], + [ + 'id' => 1, + 'status' => null, + ], + ], + 'enum nullable not null' => [ + [ + 'id' => 'int', + 'status' => '?enum[' . StatusEnum::class . ']', + ], + [ + 'id' => '1', + 'status' => 'pending', + ], + [ + 'id' => 1, + 'status' => StatusEnum::PENDING, + ], + ], ]; } @@ -321,6 +395,76 @@ public static function provideConvertDataToDB(): iterable 'temp' => 15.9, ], ], + 'enum string-backed' => [ + [ + 'id' => 'int', + 'status' => 'enum[' . StatusEnum::class . ']', + ], + [ + 'id' => 1, + 'status' => StatusEnum::ACTIVE, + ], + [ + 'id' => 1, + 'status' => 'active', + ], + ], + 'enum int-backed' => [ + [ + 'id' => 'int', + 'role' => 'enum[' . RoleEnum::class . ']', + ], + [ + 'id' => 1, + 'role' => RoleEnum::ADMIN, + ], + [ + 'id' => 1, + 'role' => 2, + ], + ], + 'enum unit' => [ + [ + 'id' => 'int', + 'color' => 'enum[' . ColorEnum::class . ']', + ], + [ + 'id' => 1, + 'color' => ColorEnum::RED, + ], + [ + 'id' => 1, + 'color' => 'RED', + ], + ], + 'enum nullable null' => [ + [ + 'id' => 'int', + 'status' => '?enum[' . StatusEnum::class . ']', + ], + [ + 'id' => 1, + 'status' => null, + ], + [ + 'id' => 1, + 'status' => null, + ], + ], + 'enum nullable not null' => [ + [ + 'id' => 'int', + 'status' => '?enum[' . StatusEnum::class . ']', + ], + [ + 'id' => 1, + 'status' => StatusEnum::PENDING, + ], + [ + 'id' => 1, + 'status' => 'pending', + ], + ], ]; } @@ -728,4 +872,116 @@ public function testExtractWithClosure(): void 'created_at' => '2023-12-02 07:35:57', ], $array); } + + /** + * @param array $types + * @param array $data + */ + #[DataProvider('provideEnumExceptions')] + public function testEnumExceptions(array $types, array $data, string $message, bool $useToDataSource): void + { + $this->expectException(CastException::class); + $this->expectExceptionMessage($message); + + $converter = $this->createDataConverter($types); + + if ($useToDataSource) { + $converter->toDataSource($data); + } else { + $converter->fromDataSource($data); + } + } + + /** + * @return iterable|bool|string>> + */ + public static function provideEnumExceptions(): iterable + { + return [ + 'get invalid backed enum value' => [ + 'types' => [ + 'id' => 'int', + 'status' => 'enum[' . StatusEnum::class . ']', + ], + 'data' => [ + 'id' => '1', + 'status' => 'invalid_status', + ], + 'message' => 'Invalid value "invalid_status" for enum "Tests\Support\Enum\StatusEnum"', + 'useToDataSource' => false, + ], + 'get invalid unit enum case name' => [ + 'types' => [ + 'id' => 'int', + 'color' => 'enum[' . ColorEnum::class . ']', + ], + 'data' => [ + 'id' => '1', + 'color' => 'YELLOW', + ], + 'message' => 'Invalid case name "YELLOW" for enum "Tests\Support\Enum\ColorEnum"', + 'useToDataSource' => false, + ], + 'get missing class' => [ + 'types' => [ + 'id' => 'int', + 'status' => 'enum', + ], + 'data' => [ + 'id' => '1', + 'status' => 'active', + ], + 'message' => 'Enum class must be specified for enum casting', + 'useToDataSource' => false, + ], + 'get not enum' => [ + 'types' => [ + 'id' => 'int', + 'status' => 'enum[stdClass]', + ], + 'data' => [ + 'id' => '1', + 'status' => 'active', + ], + 'message' => 'The "stdClass" is not a valid enum class', + 'useToDataSource' => false, + ], + 'set invalid type' => [ + 'types' => [ + 'id' => 'int', + 'status' => 'enum[' . StatusEnum::class . ']', + ], + 'data' => [ + 'id' => 1, + 'status' => ColorEnum::RED, + ], + 'message' => 'Expected enum of type "Tests\Support\Enum\StatusEnum", but received "Tests\Support\Enum\ColorEnum"', + 'useToDataSource' => true, + ], + 'set missing class' => [ + 'types' => [ + 'id' => 'int', + 'status' => 'enum', + ], + 'data' => [ + 'id' => 1, + 'status' => StatusEnum::ACTIVE, + ], + 'message' => 'Enum class must be specified for enum casting', + 'useToDataSource' => true, + ], + 'set not enum' => [ + 'types' => [ + 'id' => 'int', + 'status' => 'enum[stdClass]', + ], + 'data' => [ + 'id' => 1, + 'status' => StatusEnum::ACTIVE, + ], + 'message' => 'The "stdClass" is not a valid enum class', + 'useToDataSource' => true, + ], + ]; + } } diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 92ed11aa36b6..8abc287956b4 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -21,11 +21,15 @@ use CodeIgniter\Test\ReflectionHelper; use DateTime; use DateTimeInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use ReflectionException; use Tests\Support\Entity\Cast\CastBase64; use Tests\Support\Entity\Cast\CastPassParameters; use Tests\Support\Entity\Cast\NotExtendsBaseCast; +use Tests\Support\Enum\ColorEnum; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; use Tests\Support\SomeEntity; /** @@ -811,6 +815,204 @@ public function testCustomCastParams(): void $this->assertSame('test_nullable_type:["nullable"]', $entity->fourth); } + public function testCastEnumStringBacked(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'status' => 'enum[' . StatusEnum::class . ']', + ]; + }; + + $entity->status = 'active'; + + $this->assertInstanceOf(StatusEnum::class, $entity->status); + $this->assertSame(StatusEnum::ACTIVE, $entity->status); + $this->assertSame(['status' => 'active'], $entity->toRawArray()); + } + + public function testCastEnumIntBacked(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'role' => 'enum[' . RoleEnum::class . ']', + ]; + }; + + $entity->role = 2; + + $this->assertInstanceOf(RoleEnum::class, $entity->role); + $this->assertSame(RoleEnum::ADMIN, $entity->role); + $this->assertSame(['role' => 2], $entity->toRawArray()); + } + + public function testCastEnumUnit(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'color' => 'enum[' . ColorEnum::class . ']', + ]; + }; + + $entity->color = 'RED'; + + $this->assertInstanceOf(ColorEnum::class, $entity->color); + $this->assertSame(ColorEnum::RED, $entity->color); + $this->assertSame(['color' => 'RED'], $entity->toRawArray()); + } + + public function testCastEnumNullable(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'status' => '?enum[' . StatusEnum::class . ']', + ]; + }; + + $entity->status = null; + + $this->assertNull($entity->status); + + $entity->status = 'pending'; + + $this->assertInstanceOf(StatusEnum::class, $entity->status); + $this->assertSame(StatusEnum::PENDING, $entity->status); + } + + #[DataProvider('provideCastEnumExceptions')] + public function testCastEnumExceptions(string $castType, mixed $value, string $property, string $message, bool $useInject): void + { + $this->expectException(CastException::class); + $this->expectExceptionMessage($message); + + $entity = new class ($castType, $property) extends Entity { + protected $casts = []; + + public function __construct(string $castType, string $property) + { + $this->casts[$property] = $castType; + parent::__construct(); + } + }; + + if ($useInject) { + // Inject raw data to bypass set() validation and test get() method + $entity->injectRawData([$property => $value]); + // Trigger get() method by accessing property + $entity->{$property}; // @phpstan-ignore expr.resultUnused + } else { + // Test set() method directly + $entity->{$property} = $value; + } + } + + /** + * @return iterable> + */ + public static function provideCastEnumExceptions(): iterable + { + return [ + 'missing class' => [ + 'castType' => 'enum', + 'value' => 'active', + 'property' => 'status', + 'message' => 'Enum class must be specified for enum casting', + 'useInject' => false, + ], + 'not enum' => [ + 'castType' => 'enum[stdClass]', + 'value' => 'active', + 'property' => 'status', + 'message' => 'The "stdClass" is not a valid enum class', + 'useInject' => false, + ], + 'invalid backed enum value' => [ + 'castType' => 'enum[' . StatusEnum::class . ']', + 'value' => 'invalid_status', + 'property' => 'status', + 'message' => 'Invalid value "invalid_status" for enum "Tests\Support\Enum\StatusEnum"', + 'useInject' => false, + ], + 'invalid unit enum case name' => [ + 'castType' => 'enum[' . ColorEnum::class . ']', + 'value' => 'YELLOW', + 'property' => 'color', + 'message' => 'Invalid case name "YELLOW" for enum "Tests\Support\Enum\ColorEnum"', + 'useInject' => false, + ], + 'invalid enum type' => [ + 'castType' => 'enum[' . StatusEnum::class . ']', + 'value' => ColorEnum::RED, + 'property' => 'status', + 'message' => 'Expected enum of type "Tests\Support\Enum\StatusEnum", but received "Tests\Support\Enum\ColorEnum"', + 'useInject' => false, + ], + 'get missing class' => [ + 'castType' => 'enum', + 'value' => 'active', + 'property' => 'status', + 'message' => 'Enum class must be specified for enum casting', + 'useInject' => true, + ], + 'get not enum' => [ + 'castType' => 'enum[stdClass]', + 'value' => 'active', + 'property' => 'status', + 'message' => 'The "stdClass" is not a valid enum class', + 'useInject' => true, + ], + 'get invalid backed enum value' => [ + 'castType' => 'enum[' . StatusEnum::class . ']', + 'value' => 'invalid_status', + 'property' => 'status', + 'message' => 'Invalid value "invalid_status" for enum "Tests\Support\Enum\StatusEnum"', + 'useInject' => true, + ], + 'get invalid unit enum case name' => [ + 'castType' => 'enum[' . ColorEnum::class . ']', + 'value' => 'YELLOW', + 'property' => 'color', + 'message' => 'Invalid case name "YELLOW" for enum "Tests\Support\Enum\ColorEnum"', + 'useInject' => true, + ], + ]; + } + + public function testCastEnumSetWithBackedEnumObject(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'status' => 'enum[' . StatusEnum::class . ']', + ]; + }; + + // Assign an enum object directly + $entity->status = StatusEnum::ACTIVE; + + // Should extract the backing value for storage + $this->assertSame(['status' => 'active'], $entity->toRawArray()); + // Should return the enum object when accessed + $this->assertInstanceOf(StatusEnum::class, $entity->status); + $this->assertSame(StatusEnum::ACTIVE, $entity->status); + } + + public function testCastEnumSetWithUnitEnumObject(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'color' => 'enum[' . ColorEnum::class . ']', + ]; + }; + + // Assign a unit enum object directly + $entity->color = ColorEnum::RED; + + // Should extract the case name for storage + $this->assertSame(['color' => 'RED'], $entity->toRawArray()); + // Should return the enum object when accessed + $this->assertInstanceOf(ColorEnum::class, $entity->color); + $this->assertSame(ColorEnum::RED, $entity->color); + } + public function testAsArray(): void { $entity = $this->getEntity(); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 3438e832d55c..d91e9e2fd604 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -77,6 +77,7 @@ Libraries - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. +- **DataConverter:** Added ``EnumCast`` caster for database and entity. - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index ce5313f4115d..32f848941327 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -243,10 +243,11 @@ Scalar Type Casting ------------------- Properties can be cast to any of the following data types: -**integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri** and **int-bool**. +**integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri**, **int-bool** and **enum**. Add a question mark at the beginning of type to mark property as nullable, i.e., **?string**, **?integer**. .. note:: **int-bool** can be used since v4.3.0. +.. note:: **enum** can be used since v4.7.0. For example, if you had a User entity with an ``is_banned`` property, you can cast it as a boolean: @@ -289,6 +290,34 @@ Stored in the database as "red,yellow,green": .. note:: Casting as CSV uses PHP's internal ``implode`` and ``explode`` methods and assumes all values are string-safe and free of commas. For more complex data casts try ``array`` or ``json``. +Enum Casting +------------ + +.. versionadded:: 4.7.0 + +You can cast properties to PHP enums. You must specify the enum class name as a parameter. + +Enum casting supports: + +* **Backed enums** (string or int) - The backing value is stored in the database +* **Unit enums** - The case name is stored in the database as a string + +For example, if you had a User entity with a ``status`` property using a backed enum: + +.. literalinclude:: entities/024.php + +You can cast it in your Entity: + +.. literalinclude:: entities/025.php + +Now, when you access the ``status`` property, it will automatically be converted to a ``UserStatus`` enum instance: + +.. literalinclude:: entities/026.php + +For nullable enums: + +.. literalinclude:: entities/027.php + Custom Casting -------------- diff --git a/user_guide_src/source/models/entities/024.php b/user_guide_src/source/models/entities/024.php new file mode 100644 index 000000000000..c5730ea04b99 --- /dev/null +++ b/user_guide_src/source/models/entities/024.php @@ -0,0 +1,10 @@ + 'enum[App\Enums\UserStatus]', + ]; +} diff --git a/user_guide_src/source/models/entities/026.php b/user_guide_src/source/models/entities/026.php new file mode 100644 index 000000000000..d289af310151 --- /dev/null +++ b/user_guide_src/source/models/entities/026.php @@ -0,0 +1,17 @@ +find(1); + +// Returns a UserStatus enum instance +echo $user->status->value; // 'active' + +// Set using enum +$user->status = UserStatus::Inactive; + +// Or set using the backing value (will be converted to enum on read) +$user->status = 'pending'; + +// Note: Internally, enums are always stored as their backing value (string/int) +// in the entity's $attributes array diff --git a/user_guide_src/source/models/entities/027.php b/user_guide_src/source/models/entities/027.php new file mode 100644 index 000000000000..15f88f7b5e61 --- /dev/null +++ b/user_guide_src/source/models/entities/027.php @@ -0,0 +1,12 @@ + '?enum[App\Enums\UserStatus]', + ]; +} diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 57fb52a024c3..182aed11ac0b 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -377,6 +377,8 @@ of type to mark the field as nullable, i.e., ``?int``, ``?datetime``. +---------------+----------------+---------------------------+ |``uri`` | URI | string type | +---------------+----------------+---------------------------+ +|``enum`` | Enum | string/int type | ++---------------+----------------+---------------------------+ csv --- @@ -413,6 +415,19 @@ timestamp The timezone of the ``Time`` instance created will be the default timezone (app's timezone), not UTC. +enum +---- + +.. versionadded:: 4.7.0 + +You can cast fields to PHP enums. You must specify the enum class name as a parameter, +like ``enum[App\Enums\StatusEnum]``. + +Enum casting supports: + +* **Backed enums** (string or int) - The backing value is stored in the database +* **Unit enums** - The case name is stored in the database as a string + Custom Casting ============== diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 126e9b0b4f95..c1fa33e7414b 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2782 errors +# total 2767 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index a28b63cc43e6..964c022fc992 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1379 errors +# total 1361 errors parameters: ignoreErrors: @@ -2332,11 +2332,6 @@ parameters: count: 1 path: ../../system/Encryption/Handlers/SodiumHandler.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\ArrayCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/ArrayCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\ArrayCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2347,21 +2342,11 @@ parameters: count: 1 path: ../../system/Entity/Cast/ArrayCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\ArrayCast\:\:set\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/ArrayCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\ArrayCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/ArrayCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BaseCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2372,11 +2357,6 @@ parameters: count: 1 path: ../../system/Entity/Cast/BaseCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:set\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BaseCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2387,21 +2367,11 @@ parameters: count: 1 path: ../../system/Entity/Cast/BaseCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BooleanCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BooleanCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\BooleanCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/BooleanCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CSVCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CSVCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\CSVCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2412,21 +2382,11 @@ parameters: count: 1 path: ../../system/Entity/Cast/CSVCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CSVCast\:\:set\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CSVCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\CSVCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/CSVCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CastInterface.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2437,11 +2397,6 @@ parameters: count: 1 path: ../../system/Entity/Cast/CastInterface.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:set\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CastInterface.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2453,50 +2408,30 @@ parameters: path: ../../system/Entity/Cast/CastInterface.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\DatetimeCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Entity\\Cast\\DatetimeCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/DatetimeCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\DatetimeCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Entity\\Cast\\EnumCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 - path: ../../system/Entity/Cast/DatetimeCast.php + path: ../../system/Entity/Cast/EnumCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\FloatCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Entity\\Cast\\EnumCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 - path: ../../system/Entity/Cast/FloatCast.php + path: ../../system/Entity/Cast/EnumCast.php - message: '#^Method CodeIgniter\\Entity\\Cast\\FloatCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/FloatCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/IntBoolCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:set\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/IntBoolCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\IntegerCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/IntegerCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\IntegerCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/IntegerCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\JsonCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/JsonCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\JsonCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2507,41 +2442,21 @@ parameters: count: 1 path: ../../system/Entity/Cast/JsonCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\JsonCast\:\:set\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/JsonCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\JsonCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/JsonCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\ObjectCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/ObjectCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\ObjectCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/ObjectCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\StringCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/StringCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\StringCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/StringCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\TimestampCast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/TimestampCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\TimestampCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2552,11 +2467,6 @@ parameters: count: 1 path: ../../system/Entity/Cast/TimestampCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\URICast\:\:get\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/URICast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\URICast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 diff --git a/utils/phpstan-baseline/staticMethod.notFound.neon b/utils/phpstan-baseline/staticMethod.notFound.neon index e8bc56bf5968..cec945e226cf 100644 --- a/utils/phpstan-baseline/staticMethod.notFound.neon +++ b/utils/phpstan-baseline/staticMethod.notFound.neon @@ -1,7 +1,17 @@ -# total 20 errors +# total 23 errors parameters: ignoreErrors: + - + message: '#^Call to an undefined static method UnitEnum\:\:tryFrom\(\)\.$#' + count: 1 + path: ../../system/DataCaster/Cast/EnumCast.php + + - + message: '#^Call to an undefined static method UnitEnum\:\:tryFrom\(\)\.$#' + count: 2 + path: ../../system/Entity/Cast/EnumCast.php + - message: '#^Call to an undefined static method CodeIgniter\\Config\\Factories\:\:cells\(\)\.$#' count: 1 From 01a45beccff6e04daebc6f6d08d9f33a27d2705c Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Fri, 10 Oct 2025 02:36:50 -0500 Subject: [PATCH 33/84] refactor(app): Standardize subdomain detection logic (#9751) * refactor(app): Standardize subdomain detection logic * Update app/Config/Hostnames.php Co-authored-by: Pooya Parsa * Update tests/system/Helpers/URLHelper/MiscUrlTest.php Co-authored-by: Pooya Parsa * addressing review comments * cs fix * cs fix * cs fix * remove typo in docs ci-skip --------- Co-authored-by: Pooya Parsa --- app/Config/Hostnames.php | 40 +++++++++++ system/Helpers/url_helper.php | 50 +++++++++++++ system/Router/Attributes/Restrict.php | 70 +------------------ system/Router/RouteCollection.php | 46 +----------- .../system/Helpers/URLHelper/MiscUrlTest.php | 36 ++++++++++ user_guide_src/source/helpers/url_helper.rst | 15 ++++ .../source/helpers/url_helper/027.php | 14 ++++ 7 files changed, 157 insertions(+), 114 deletions(-) create mode 100644 app/Config/Hostnames.php create mode 100644 user_guide_src/source/helpers/url_helper/027.php diff --git a/app/Config/Hostnames.php b/app/Config/Hostnames.php new file mode 100644 index 000000000000..1b4c7224ee8b --- /dev/null +++ b/app/Config/Hostnames.php @@ -0,0 +1,40 @@ +getUri()->getHost(); + } + + // Handle localhost and IP addresses - they don't have subdomains + if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) { + return ''; + } + + $parts = explode('.', $host); + $partCount = count($parts); + + // Need at least 3 parts for a subdomain (subdomain.domain.tld) + // e.g., api.example.com + if ($partCount < 3) { + return ''; + } + + // Check if we have a two-part TLD (e.g., co.uk, com.au) + $lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1]; + + if (in_array($lastTwoParts, Hostnames::TWO_PART_TLDS, true)) { + // For two-part TLD, need at least 4 parts for subdomain + // e.g., api.example.co.uk (4 parts) + if ($partCount < 4) { + return ''; // No subdomain, just domain.co.uk + } + + // Remove the two-part TLD and domain name (last 3 parts) + // e.g., admin.api.example.co.uk -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 3)); + } + + // Standard TLD: Remove TLD and domain (last 2 parts) + // e.g., admin.api.example.com -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 2)); + } +} diff --git a/system/Router/Attributes/Restrict.php b/system/Router/Attributes/Restrict.php index e8738befd31e..36c087e78f9b 100644 --- a/system/Router/Attributes/Restrict.php +++ b/system/Router/Attributes/Restrict.php @@ -42,38 +42,6 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Restrict implements RouteAttributeInterface { - private const TWO_PART_TLDS = [ - 'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk', - 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au', - 'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp', - 'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz', - 'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in', - 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', - 'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg', - 'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za', - 'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr', - 'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th', - 'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my', - 'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx', - 'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br', - 'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il', - 'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id', - 'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk', - 'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw', - 'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa', - 'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae', - 'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr', - 'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke', - 'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng', - 'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk', - 'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg', - 'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy', - 'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk', - 'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd', - 'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar', - 'gob.cl', - ]; - public function __construct( public array|string|null $environment = null, public array|string|null $hostname = null, @@ -145,7 +113,7 @@ private function checkSubdomain(RequestInterface $request): void return; } - $currentSubdomain = $this->getSubdomain($request); + $currentSubdomain = parse_subdomain($request->getUri()->getHost()); $allowedSubdomains = array_map('strtolower', (array) $this->subdomain); // If no subdomain exists but one is required @@ -158,40 +126,4 @@ private function checkSubdomain(RequestInterface $request): void throw new PageNotFoundException('Access denied: subdomain is blocked.'); } } - - private function getSubdomain(RequestInterface $request): string - { - $host = strtolower($request->getUri()->getHost()); - - // Handle localhost and IP addresses - they don't have subdomains - if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) { - return ''; - } - - $parts = explode('.', $host); - $partCount = count($parts); - - // Need at least 3 parts for a subdomain (subdomain.domain.tld) - // e.g., api.example.com - if ($partCount < 3) { - return ''; - } - // Check if we have a two-part TLD (e.g., co.uk, com.au) - $lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1]; - if (in_array($lastTwoParts, self::TWO_PART_TLDS, true)) { - // For two-part TLD, need at least 4 parts for subdomain - // e.g., api.example.co.uk (4 parts) - if ($partCount < 4) { - return ''; // No subdomain, just domain.co.uk - } - - // Remove the two-part TLD and domain name (last 3 parts) - // e.g., admin.api.example.co.uk -> admin.api - return implode('.', array_slice($parts, 0, $partCount - 3)); - } - - // Standard TLD: Remove TLD and domain (last 2 parts) - // e.g., admin.api.example.com -> admin.api - return implode('.', array_slice($parts, 0, $partCount - 2)); - } } diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 7379d014cc09..c89de601cb47 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -1637,7 +1637,7 @@ private function checkSubdomains($subdomains): bool } if ($this->currentSubdomain === null) { - $this->currentSubdomain = $this->determineCurrentSubdomain(); + $this->currentSubdomain = parse_subdomain($this->httpHost); } if (! is_array($subdomains)) { @@ -1653,50 +1653,6 @@ private function checkSubdomains($subdomains): bool return in_array($this->currentSubdomain, $subdomains, true); } - /** - * Examines the HTTP_HOST to get the best match for the subdomain. It - * won't be perfect, but should work for our needs. - * - * It's especially not perfect since it's possible to register a domain - * with a period (.) as part of the domain name. - * - * @return false|string the subdomain - */ - private function determineCurrentSubdomain() - { - // We have to ensure that a scheme exists - // on the URL else parse_url will mis-interpret - // 'host' as the 'path'. - $url = $this->httpHost; - if (! str_starts_with($url, 'http')) { - $url = 'http://' . $url; - } - - $parsedUrl = parse_url($url); - - $host = explode('.', $parsedUrl['host']); - - if ($host[0] === 'www') { - unset($host[0]); - } - - // Get rid of any domains, which will be the last - unset($host[count($host) - 1]); - - // Account for .co.uk, .co.nz, etc. domains - if (end($host) === 'co') { - $host = array_slice($host, 0, -1); - } - - // If we only have 1 part left, then we don't have a sub-domain. - if (count($host) === 1) { - // Set it to false so we don't make it back here again. - return false; - } - - return array_shift($host); - } - /** * Reset the routes, so that a test case can provide the * explicit ones needed for it. diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index 81ec9770ea2a..01ef146401f8 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -963,4 +963,40 @@ public function testUrlToMissingArgument(): void url_to('loginURL'); } + + #[DataProvider('provideParseSubdomain')] + public function testParseSubdomain(?string $host, string $expected, bool $useRequest = false): void + { + if ($useRequest) { + // create a request whose host will be used when passing null to parse_subdomain + $this->config->baseURL = 'http://sub.example.com/'; + $this->createRequest('http://sub.example.com/'); + + $this->assertSame($expected, parse_subdomain(null)); + + return; + } + + $this->assertSame($expected, parse_subdomain($host)); + } + + /** + * Provides test cases for parsing subdomains. + * + * @return array + */ + public static function provideParseSubdomain(): iterable + { + return [ + 'standard subdomain' => ['api.example.com', 'api', false], + 'multi-level subdomain' => ['admin.api.example.com', 'admin.api', false], + 'no subdomain (domain only)' => ['example.com', '', false], + 'localhost' => ['localhost', '', false], + 'ipv4' => ['127.0.0.1', '', false], + 'ipv6' => ['::1', '', false], + 'two-part tld no subdomain' => ['example.co.uk', '', false], + 'two-part tld with subdomain' => ['api.example.co.uk', 'api', false], + 'null uses request host' => [null, 'sub', true], + ]; + } } diff --git a/user_guide_src/source/helpers/url_helper.rst b/user_guide_src/source/helpers/url_helper.rst index 44e52dd0d8a3..0225e730f9dc 100644 --- a/user_guide_src/source/helpers/url_helper.rst +++ b/user_guide_src/source/helpers/url_helper.rst @@ -361,6 +361,21 @@ The following functions are available: This function works the same as :php:func:`url_title()` but it converts all accented characters automatically. +.. php:function:: parse_subdomain($hostname) + + :param string|null $hostname: The hostname to parse. If null, uses the current request's host. + :returns: The subdomain, or an empty string if none exists. + :rtype: string + + Parses the subdomain from the given host name. + + Here are some examples: + + .. literalinclude:: url_helper/027.php + + You can customize the list of known two-part TLDs by adding them to the + ``Config\Hostnames::TWO_PART_TLDS`` array. + .. php:function:: prep_url([$str = ''[, $secure = false]]) :param string $str: URL string diff --git a/user_guide_src/source/helpers/url_helper/027.php b/user_guide_src/source/helpers/url_helper/027.php new file mode 100644 index 000000000000..aa3a4e3fbbea --- /dev/null +++ b/user_guide_src/source/helpers/url_helper/027.php @@ -0,0 +1,14 @@ + Date: Wed, 15 Oct 2025 09:00:52 +0700 Subject: [PATCH 34/84] chore: bump `laminas/laminas-escaper` to v1.28 as minimum chore: bump to v2.18 as minimum --- admin/framework/composer.json | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/framework/composer.json b/admin/framework/composer.json index 44b42109f824..7b03d9886bf7 100644 --- a/admin/framework/composer.json +++ b/admin/framework/composer.json @@ -13,7 +13,7 @@ "php": "^8.2", "ext-intl": "*", "ext-mbstring": "*", - "laminas/laminas-escaper": "^2.17", + "laminas/laminas-escaper": "^2.18", "psr/log": "^3.0" }, "require-dev": { diff --git a/composer.json b/composer.json index e244e9e7b6a1..2597bfdb33c0 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "php": "^8.2", "ext-intl": "*", "ext-mbstring": "*", - "laminas/laminas-escaper": "^2.17", + "laminas/laminas-escaper": "^2.18", "psr/log": "^3.0" }, "require-dev": { From 815a02bb5e6486a51b88d8de53b32b10ad60c0eb Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Wed, 15 Oct 2025 10:43:56 +0700 Subject: [PATCH 35/84] run composer update --- system/ThirdParty/Escaper/Escaper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/ThirdParty/Escaper/Escaper.php b/system/ThirdParty/Escaper/Escaper.php index 39d9b0b1cdac..25119a0e5158 100644 --- a/system/ThirdParty/Escaper/Escaper.php +++ b/system/ThirdParty/Escaper/Escaper.php @@ -247,7 +247,7 @@ public function escapeCss(string $string) protected function htmlAttrMatcher($matches) { $chr = $matches[0]; - $ord = ord($chr); + $ord = ord($chr[0]); /** * The following replaces characters undefined in HTML with the From 5add16dab3bad19c8ab399cb7dc45c56a8a64451 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 01:42:09 -0500 Subject: [PATCH 36/84] feat(app): Added pagination response to API ResponseTrait (#9758) * feat(app): Added pagination response to API ResponseTrait * chore(app): Updating deptrack for pagination method Updated deptrac.yaml to allow the API layer to also depend on: * docs(app): Fixing docs error and adding changelog * docs(app): Added missing label to docs * chore(app): PHPStan baseline --- .gitignore | 3 + deptrac.yaml | 4 + system/API/ResponseTrait.php | 151 ++++++- system/Language/en/RESTful.php | 2 + tests/system/API/ResponseTraitTest.php | 371 ++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + .../source/outgoing/api_responses.rst | 85 +++- .../source/outgoing/api_responses/018.php | 18 + .../source/outgoing/api_responses/019.php | 18 + .../function.alreadyNarrowedType.neon | 13 + .../function.impossibleType.neon | 18 + utils/phpstan-baseline/loader.neon | 4 +- utils/phpstan-baseline/property.notFound.neon | 32 +- 13 files changed, 680 insertions(+), 40 deletions(-) create mode 100644 user_guide_src/source/outgoing/api_responses/018.php create mode 100644 user_guide_src/source/outgoing/api_responses/019.php create mode 100644 utils/phpstan-baseline/function.alreadyNarrowedType.neon create mode 100644 utils/phpstan-baseline/function.impossibleType.neon diff --git a/.gitignore b/.gitignore index 988be09b1948..485512fa4a7b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ $RECYCLE.BIN/ .env .vagrant Vagrantfile +user_guide_src/venv/ +.python-version +user_guide_src/.python-version #------------------------- # Temporary Files diff --git a/deptrac.yaml b/deptrac.yaml index 7f5687af4bca..c70f4d7126cc 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -164,6 +164,10 @@ deptrac: API: - Format - HTTP + - Database + - Model + - Pager + - URI Cache: - I18n Controller: diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index f64cc671a514..cc04fa509fd9 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -13,11 +13,16 @@ namespace CodeIgniter\API; +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Format\Format; use CodeIgniter\Format\FormatterInterface; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Model; +use Throwable; /** * Provides common, more readable, methods to provide @@ -321,7 +326,7 @@ protected function format($data = null) // if we don't have a formatter, make one $this->formatter ??= $format->getFormatter($mime); - $asHtml = $this->stringAsHtml ?? false; + $asHtml = property_exists($this, 'stringAsHtml') ? $this->stringAsHtml : false; if ( ($mime === 'application/json' && $asHtml && is_string($data)) @@ -360,4 +365,148 @@ protected function setResponseFormat(?string $format = null) return $this; } + + // -------------------------------------------------------------------- + // Pagination Methods + // -------------------------------------------------------------------- + + /** + * Paginates the given model or query builder and returns + * an array containing the paginated results along with + * metadata such as total items, total pages, current page, + * and items per page. + * + * The result would be in the following format: + * [ + * 'data' => [...], + * 'meta' => [ + * 'page' => 1, + * 'perPage' => 20, + * 'total' => 100, + * 'totalPages' => 5, + * ], + * 'links' => [ + * 'self' => '/api/items?page=1&perPage=20', + * 'first' => '/api/items?page=1&perPage=20', + * 'last' => '/api/items?page=5&perPage=20', + * 'prev' => null, + * 'next' => '/api/items?page=2&perPage=20', + * ] + * ] + */ + protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface + { + try { + assert($this->request instanceof IncomingRequest); + + $page = max(1, (int) ($this->request->getGet('page') ?? 1)); + + // If using a Model we can use its built-in paginate method + if ($resource instanceof Model) { + $data = $resource->paginate($perPage, 'default', $page); + $pager = $resource->pager; + + $meta = [ + 'page' => $pager->getCurrentPage(), + 'perPage' => $pager->getPerPage(), + 'total' => $pager->getTotal(), + 'totalPages' => $pager->getPageCount(), + ]; + } else { + // Query Builder, we need to handle pagination manually + $offset = ($page - 1) * $perPage; + $total = (clone $resource)->countAllResults(); + $data = $resource->limit($perPage, $offset)->get()->getResultArray(); + + $meta = [ + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total, + 'totalPages' => (int) ceil($total / $perPage), + ]; + } + + $links = $this->buildLinks($meta); + + $this->response->setHeader('Link', $this->linkHeader($links)); + $this->response->setHeader('X-Total-Count', (string) $meta['total']); + + return $this->respond([ + 'data' => $data, + 'meta' => $meta, + 'links' => $links, + ]); + } catch (DatabaseException $e) { + log_message('error', lang('RESTful.cannotPaginate') . ' ' . $e->getMessage()); + + return $this->failServerError(lang('RESTful.cannotPaginate')); + } catch (Throwable $e) { + log_message('error', lang('RESTful.paginateError') . ' ' . $e->getMessage()); + + return $this->failServerError(lang('RESTful.paginateError')); + } + } + + /** + * Builds pagination links based on the current request URI and pagination metadata. + * + * @param array $meta Pagination metadata (page, perPage, total, totalPages) + * + * @return array Array of pagination links with relations as keys + */ + private function buildLinks(array $meta): array + { + assert($this->request instanceof IncomingRequest); + + /** @var URI $uri */ + $uri = current_url(true); + $query = $this->request->getGet(); + + $set = static function ($page) use ($uri, $query, $meta): string { + $params = $query; + $params['page'] = $page; + + // Ensure perPage is in the links if it's not default + if (! isset($params['perPage']) && $meta['perPage'] !== 20) { + $params['perPage'] = $meta['perPage']; + } + + return (string) (new URI((string) $uri))->setQuery(http_build_query($params)); + }; + + $totalPages = max(1, (int) $meta['totalPages']); + $page = (int) $meta['page']; + + return [ + 'self' => $set($page), + 'first' => $set(1), + 'last' => $set($totalPages), + 'prev' => $page > 1 ? $set($page - 1) : null, + 'next' => $page < $totalPages ? $set($page + 1) : null, + ]; + } + + /** + * Formats the pagination links into a single Link header string + * for middleware/machine use. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link + * @see https://datatracker.ietf.org/doc/html/rfc8288 + * + * @param array $links Pagination links with relations as keys + * + * @return string Formatted Link header value + */ + private function linkHeader(array $links): string + { + $parts = []; + + foreach (['self', 'first', 'prev', 'next', 'last'] as $rel) { + if ($links[$rel] !== null && $links[$rel] !== '') { + $parts[] = "<{$links[$rel]}>; rel=\"{$rel}\""; + } + } + + return implode(', ', $parts); + } } diff --git a/system/Language/en/RESTful.php b/system/Language/en/RESTful.php index 59e014b72f7e..e1aaa10f12d5 100644 --- a/system/Language/en/RESTful.php +++ b/system/Language/en/RESTful.php @@ -14,4 +14,6 @@ // RESTful language settings return [ 'notImplemented' => '"{0}" action not implemented.', + 'cannotPaginate' => 'Unable to retrieve paginated data.', + 'paginateError' => 'An error occurred while paginating results.', ]; diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index 0443d90dc391..cc8748ce1b4a 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -14,6 +14,10 @@ namespace CodeIgniter\API; use CodeIgniter\Config\Factories; +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Format\FormatterInterface; use CodeIgniter\Format\JSONFormatter; use CodeIgniter\Format\XMLFormatter; @@ -21,12 +25,15 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Model; +use CodeIgniter\Pager\Pager; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockIncomingRequest; use CodeIgniter\Test\Mock\MockResponse; use Config\App; use Config\Cookie; use Config\Services; +use Exception; use PHPUnit\Framework\Attributes\Group; use stdClass; @@ -682,4 +689,368 @@ private function invoke(object $controller, string $method, array $args = []): o return $method(...$args); } + + /** + * Helper method to create a mock Model with a mock Pager + * + * @param array> $data + */ + private function createMockModelWithPager(array $data, int $page, int $perPage, int $total, int $totalPages): Model + { + // Create a mock Pager + $pager = $this->createMock(Pager::class); + $pager->method('getCurrentPage')->willReturn($page); + $pager->method('getPerPage')->willReturn($perPage); + $pager->method('getTotal')->willReturn($total); + $pager->method('getPageCount')->willReturn($totalPages); + + // Create a mock Model with a public pager property + $model = $this->createMock(Model::class); + + $model->method('paginate')->willReturn($data); + $model->pager = $pager; + + return $model; + } + + public function testPaginateWithModel(): void + { + // Mock data + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 50, 3); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Check response structure + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertArrayHasKey('data', $responseBody); + $this->assertArrayHasKey('meta', $responseBody); + $this->assertArrayHasKey('links', $responseBody); + + // Check meta + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(20, $responseBody['meta']['perPage']); + $this->assertSame(50, $responseBody['meta']['total']); + $this->assertSame(3, $responseBody['meta']['totalPages']); + + // Check data + $this->assertSame($data, $responseBody['data']); + + // Check headers + $this->assertSame('50', $this->response->getHeaderLine('X-Total-Count')); + $this->assertNotEmpty($this->response->getHeaderLine('Link')); + } + + public function testPaginateWithQueryBuilder(): void + { + // Mock the database and builder + $db = $this->createMock(BaseConnection::class); + + $builder = $this->getMockBuilder(BaseBuilder::class) + ->setConstructorArgs(['test_table', $db]) + ->onlyMethods(['countAllResults', 'limit', 'get']) + ->getMock(); + + $result = $this->createMock(BaseResult::class); + + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + // Mock the query builder chain + $builder->method('countAllResults')->willReturn(50); + $builder->method('limit')->willReturnSelf(); + $builder->method('get')->willReturn($result); + $result->method('getResultArray')->willReturn($data); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$builder, 20]); + + // Check response structure + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertArrayHasKey('data', $responseBody); + $this->assertArrayHasKey('meta', $responseBody); + $this->assertArrayHasKey('links', $responseBody); + + // Check meta + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(20, $responseBody['meta']['perPage']); + $this->assertSame(50, $responseBody['meta']['total']); + $this->assertSame(3, $responseBody['meta']['totalPages']); + } + + public function testPaginateWithCustomPerPage(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ['id' => 3, 'name' => 'Item 3'], + ['id' => 4, 'name' => 'Item 4'], + ['id' => 5, 'name' => 'Item 5'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 5, 25, 5); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 5]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check meta with custom perPage + $this->assertSame(5, $responseBody['meta']['perPage']); + $this->assertSame(25, $responseBody['meta']['total']); + $this->assertSame(5, $responseBody['meta']['totalPages']); + } + + public function testPaginateWithPageParameter(): void + { + $data = [ + ['id' => 21, 'name' => 'Item 21'], + ['id' => 22, 'name' => 'Item 22'], + ]; + + $model = $this->createMockModelWithPager($data, 2, 20, 50, 3); + + // Create controller with page=2 in query string + $controller = $this->makeController('/api/items?page=2'); + Services::superglobals()->setGet('page', '2'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that we're on page 2 + $this->assertSame(2, $responseBody['meta']['page']); + + // Check links + $this->assertStringContainsString('page=2', (string) $responseBody['links']['self']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['prev']); + $this->assertStringContainsString('page=3', (string) $responseBody['links']['next']); + } + + public function testPaginateLinksStructure(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 2, 20, 100, 5); + + Services::superglobals()->setGet('page', '2'); + $controller = $this->makeController('/api/items?page=2'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check all link types exist + $this->assertArrayHasKey('self', $responseBody['links']); + $this->assertArrayHasKey('first', $responseBody['links']); + $this->assertArrayHasKey('last', $responseBody['links']); + $this->assertArrayHasKey('prev', $responseBody['links']); + $this->assertArrayHasKey('next', $responseBody['links']); + + // Check link values + $this->assertStringContainsString('page=2', (string) $responseBody['links']['self']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['first']); + $this->assertStringContainsString('page=5', (string) $responseBody['links']['last']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['prev']); + $this->assertStringContainsString('page=3', (string) $responseBody['links']['next']); + } + + public function testPaginateFirstPageNoPrevLink(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 20, 50, 3); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // First page should not have a prev link + $this->assertNull($responseBody['links']['prev']); + // But should have a next link + $this->assertNotNull($responseBody['links']['next']); + } + + public function testPaginateLastPageNoNextLink(): void + { + $data = [['id' => 41, 'name' => 'Item 41']]; + + $model = $this->createMockModelWithPager($data, 3, 20, 50, 3); + + Services::superglobals()->setGet('page', '3'); + $controller = $this->makeController('/api/items?page=3'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Last page should not have a next link + $this->assertNull($responseBody['links']['next']); + // But should have a prev link + $this->assertNotNull($responseBody['links']['prev']); + } + + public function testPaginateLinkHeader(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 2, 20, 100, 5); + + Services::superglobals()->setGet('page', '2'); + $controller = $this->makeController('/api/items?page=2'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $linkHeader = $this->response->getHeaderLine('Link'); + + // Check that Link header is properly formatted + $this->assertStringContainsString('rel="self"', $linkHeader); + $this->assertStringContainsString('rel="first"', $linkHeader); + $this->assertStringContainsString('rel="last"', $linkHeader); + $this->assertStringContainsString('rel="prev"', $linkHeader); + $this->assertStringContainsString('rel="next"', $linkHeader); + + // Check format ; rel="relation" + $this->assertMatchesRegularExpression('/<[^>]+>;\s*rel="self"/', $linkHeader); + $this->assertMatchesRegularExpression('/<[^>]+>;\s*rel="first"/', $linkHeader); + } + + public function testPaginateXTotalCountHeader(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 20, 150, 8); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Check X-Total-Count header + $this->assertSame('150', $this->response->getHeaderLine('X-Total-Count')); + } + + public function testPaginateWithDatabaseException(): void + { + $model = $this->createMock(Model::class); + + // Make the model throw a DatabaseException + $model->method('paginate')->willThrowException( + new DatabaseException('Database error'), + ); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Should return a 500 error + $this->assertSame(500, $this->response->getStatusCode()); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check error response structure + $this->assertArrayHasKey('status', $responseBody); + $this->assertArrayHasKey('error', $responseBody); + $this->assertArrayHasKey('messages', $responseBody); + $this->assertSame(500, $responseBody['status']); + } + + public function testPaginateWithGenericException(): void + { + $model = $this->createMock(Model::class); + + // Make the model throw a generic exception + $model->method('paginate')->willThrowException( + new Exception('Generic error'), + ); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Should return a 500 error + $this->assertSame(500, $this->response->getStatusCode()); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check error response structure + $this->assertSame(500, $responseBody['status']); + $this->assertArrayHasKey('error', $responseBody); + } + + public function testPaginateWithNonDefaultPerPageInLinks(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 10, 50, 5); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 10]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that perPage is included in links when it's not the default (20) + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['self']); + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['first']); + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['last']); + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['next']); + } + + public function testPaginatePreservesOtherQueryParameters(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 20, 50, 3); + + Services::superglobals()->setGet('filter', 'active'); + Services::superglobals()->setGet('sort', 'name'); + $controller = $this->makeController('/api/items?filter=active&sort=name'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that other query parameters are preserved in links + $this->assertStringContainsString('filter=active', (string) $responseBody['links']['self']); + $this->assertStringContainsString('sort=name', (string) $responseBody['links']['self']); + $this->assertStringContainsString('filter=active', (string) $responseBody['links']['next']); + $this->assertStringContainsString('sort=name', (string) $responseBody['links']['next']); + } + + public function testPaginateSinglePage(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 2, 1); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // For a single page, prev and next should be null + $this->assertNull($responseBody['links']['prev']); + $this->assertNull($responseBody['links']['next']); + // First and last should point to page 1 + $this->assertStringContainsString('page=1', (string) $responseBody['links']['first']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['last']); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index d91e9e2fd604..bd93171be60e 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -81,6 +81,7 @@ Libraries - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. +- **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` Commands diff --git a/user_guide_src/source/outgoing/api_responses.rst b/user_guide_src/source/outgoing/api_responses.rst index eee219591f8f..645951cf4347 100644 --- a/user_guide_src/source/outgoing/api_responses.rst +++ b/user_guide_src/source/outgoing/api_responses.rst @@ -1,9 +1,9 @@ -################## -API Response Trait -################## +############# +API Responses +############# Much of modern PHP development requires building APIs, whether simply to provide data for a javascript-heavy -single page application, or as a standalone product. CodeIgniter provides an API Response trait that can be +single page application, or as a standalone product. CodeIgniter provides a couple of traits that can be used with any controller to make common response types simple, with no need to remember which HTTP status code should be returned for which response types. @@ -11,9 +11,9 @@ should be returned for which response types. :local: :depth: 2 -************* -Example Usage -************* +***************** +Response Examples +***************** The following example shows a common usage pattern within your controllers. @@ -250,3 +250,74 @@ Class Reference Sets the appropriate status code to use when there is a server error. .. literalinclude:: api_responses/017.php + +.. _api_response_trait_paginate: + +******************** +Pagination Responses +******************** + +When returning paginated results from an API endpoint, you can use the ``paginate()`` method to return the +results along with the pagination information. This helps to keep consistent responses across your API, while +providing all of the information that clients will need to properly page through the results. + +------------- +Example Usage +------------- + +.. literalinclude:: api_responses/018.php + +A typical response might look like: + +.. code-block:: json + + { + "data": [ + { + "id": 1, + "username": "admin", + "email": "admin@example.com" + }, + { + "id": 2, + "username": "user", + "email": "user@example.com" + } + ], + "meta": { + "page": 1, + "perPage": 20, + "total": 2, + "totalPages": 1 + }, + "links": { + "self": "http://example.com/users?page=1", + "first": "http://example.com/users?page=1", + "last": "http://example.com/users?page=1", + "next": null, + "previous": null + } + } + +The ``paginate()`` method will always wrap the results in a ``data`` element, and will also include ``meta`` +and ``links`` elements to help the client page through the results. If there are no results, the ``data`` element will +be an empty array, and the ``meta`` and ``links`` elements will still be present, but with values that indicate no results. + +You can also pass it a Builder instance instead of a Model, as long as the Builder is properly configured with the table +name and any necessary joins or where clauses. + +.. literalinclude:: api_responses/019.php + +*************** +Class Reference +*************** + +.. php:method:: paginate(Model|BaseBuilder $resource, int $perPage = 20) + + :param Model|BaseBuilder $resource: The resource to paginate, either a Model or a Builder instance. + :param int $perPage: The number of items to return per page. + + Generates a paginated response from the given resource. The resource can be either a Model or a Builder + instance. The method will automatically determine the current page from the request's query parameters. + The response will include the paginated data, along with metadata about the pagination state and links + to navigate through the pages. diff --git a/user_guide_src/source/outgoing/api_responses/018.php b/user_guide_src/source/outgoing/api_responses/018.php new file mode 100644 index 000000000000..efacd81ac89b --- /dev/null +++ b/user_guide_src/source/outgoing/api_responses/018.php @@ -0,0 +1,18 @@ +where('active', 1); + + return $this->paginate($model, 20); + } +} diff --git a/user_guide_src/source/outgoing/api_responses/019.php b/user_guide_src/source/outgoing/api_responses/019.php new file mode 100644 index 000000000000..63f249085c8a --- /dev/null +++ b/user_guide_src/source/outgoing/api_responses/019.php @@ -0,0 +1,18 @@ +table('users') + ->where('active', 1); + + return $this->paginate(resource: $builder, perPage: 20); + } +} diff --git a/utils/phpstan-baseline/function.alreadyNarrowedType.neon b/utils/phpstan-baseline/function.alreadyNarrowedType.neon new file mode 100644 index 000000000000..7f6d5a1b1fdd --- /dev/null +++ b/utils/phpstan-baseline/function.alreadyNarrowedType.neon @@ -0,0 +1,13 @@ +# total 2 errors + +parameters: + ignoreErrors: + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:188\) and ''stringAsHtml'' will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php + + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:308\) and ''stringAsHtml'' will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/function.impossibleType.neon b/utils/phpstan-baseline/function.impossibleType.neon new file mode 100644 index 000000000000..431a49bc5aed --- /dev/null +++ b/utils/phpstan-baseline/function.impossibleType.neon @@ -0,0 +1,18 @@ +# total 3 errors + +parameters: + ignoreErrors: + - + message: '#^Call to function property_exists\(\) with \$this\(CodeIgniter\\Debug\\ExceptionHandler\) and ''stringAsHtml'' will always evaluate to false\.$#' + count: 1 + path: ../../system/Debug/ExceptionHandler.php + + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:130\) and ''stringAsHtml'' will always evaluate to false\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php + + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:628\) and ''stringAsHtml'' will always evaluate to false\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index c1fa33e7414b..07dad94ede7f 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2767 errors +# total 2766 errors includes: - argument.type.neon @@ -10,6 +10,8 @@ includes: - codeigniter.superglobalAccessAssign.neon - deadCode.unreachable.neon - empty.notAllowed.neon + - function.alreadyNarrowedType.neon + - function.impossibleType.neon - method.alreadyNarrowedType.neon - method.childParameterType.neon - method.childReturnType.neon diff --git a/utils/phpstan-baseline/property.notFound.neon b/utils/phpstan-baseline/property.notFound.neon index 3fab0404eb87..3d66f73b98bc 100644 --- a/utils/phpstan-baseline/property.notFound.neon +++ b/utils/phpstan-baseline/property.notFound.neon @@ -1,4 +1,4 @@ -# total 57 errors +# total 51 errors parameters: ignoreErrors: @@ -17,21 +17,6 @@ parameters: count: 14 path: ../../system/Database/SQLSRV/Forge.php - - - message: '#^Access to an undefined property CodeIgniter\\Debug\\ExceptionHandler\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/Debug/ExceptionHandler.php - - - - message: '#^Access to an undefined property CodeIgniter\\Debug\\Exceptions\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - - - message: '#^Access to an undefined property CodeIgniter\\RESTful\\ResourceController\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/RESTful/ResourceController.php - - message: '#^Access to an undefined property Config\\Session\:\:\$lockAttempts\.$#' count: 1 @@ -42,21 +27,6 @@ parameters: count: 1 path: ../../system/Session/Handlers/RedisHandler.php - - - message: '#^Access to an undefined property CodeIgniter\\Test\\Mock\\MockResourcePresenter\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/Test/Mock/MockResourcePresenter.php - - - - message: '#^Access to an undefined property class@anonymous/tests/system/API/ResponseTraitTest\.php\:123\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php - - - - message: '#^Access to an undefined property class@anonymous/tests/system/API/ResponseTraitTest\.php\:621\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php - - message: '#^Access to an undefined property CodeIgniter\\Database\\BaseConnection\:\:\$mysqli\.$#' count: 1 From 05c721798707d7da10d588174e5387b29f4be833 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Sun, 19 Oct 2025 07:09:02 -0500 Subject: [PATCH 37/84] feat: API transformers (#9763) * feat(app): Added API Transformers * chore(app): Style, Rector, and Stan changes * chore(app): Remove unneccessary file * (app): Remove when / whenNot from BaseTransformer * refactor(app): Remove Entitiy dependency in BaseTransformer * fix(app): Fixing a Psalm issue with transform method * docs(app): Fix an error with user guide builds * docs(app): Fix docs issue with overline length_ * chore(app): Update PHPStan baseline for test issues * docs(app): Additional docs fixes * fix(app): Addressing review comments * build(app): Apply Rector fix --- loader.neon | 4 + system/API/ApiException.php | 63 ++ system/API/BaseTransformer.php | 221 +++++++ system/API/ResponseTrait.php | 22 +- system/API/TransformerInterface.php | 51 ++ .../Generators/TransformerGenerator.php | 94 +++ .../Generators/Views/transformer.tpl.php | 22 + system/Language/en/Api.php | 21 + system/Language/en/CLI.php | 25 +- tests/_support/API/InvalidTransformer.php | 26 + tests/_support/API/TestTransformer.php | 37 ++ tests/system/API/ResponseTraitTest.php | 160 +++++ tests/system/API/TransformerTest.php | 597 ++++++++++++++++++ .../Commands/TransformerGeneratorTest.php | 100 +++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/cli/cli_generators.rst | 21 + .../source/outgoing/api_responses.rst | 12 +- .../source/outgoing/api_responses/020.php | 18 + .../source/outgoing/api_transformers.rst | 358 +++++++++++ .../source/outgoing/api_transformers/001.php | 24 + .../source/outgoing/api_transformers/002.php | 18 + .../source/outgoing/api_transformers/003.php | 33 + .../source/outgoing/api_transformers/007.php | 22 + .../source/outgoing/api_transformers/008.php | 25 + .../source/outgoing/api_transformers/009.php | 32 + .../source/outgoing/api_transformers/010.php | 48 ++ .../source/outgoing/api_transformers/011.php | 23 + .../source/outgoing/api_transformers/012.php | 21 + .../source/outgoing/api_transformers/013.php | 13 + .../source/outgoing/api_transformers/014.php | 12 + .../source/outgoing/api_transformers/015.php | 11 + .../source/outgoing/api_transformers/016.php | 21 + .../source/outgoing/api_transformers/017.php | 19 + .../source/outgoing/api_transformers/018.php | 17 + .../source/outgoing/api_transformers/019.php | 13 + .../source/outgoing/api_transformers/022.php | 25 + .../source/outgoing/api_transformers/023.php | 39 ++ user_guide_src/source/outgoing/index.rst | 1 + .../function.alreadyNarrowedType.neon | 4 +- .../function.impossibleType.neon | 4 +- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 52 +- 42 files changed, 2312 insertions(+), 20 deletions(-) create mode 100644 loader.neon create mode 100644 system/API/ApiException.php create mode 100644 system/API/BaseTransformer.php create mode 100644 system/API/TransformerInterface.php create mode 100644 system/Commands/Generators/TransformerGenerator.php create mode 100644 system/Commands/Generators/Views/transformer.tpl.php create mode 100644 system/Language/en/Api.php create mode 100644 tests/_support/API/InvalidTransformer.php create mode 100644 tests/_support/API/TestTransformer.php create mode 100644 tests/system/API/TransformerTest.php create mode 100644 tests/system/Commands/TransformerGeneratorTest.php create mode 100644 user_guide_src/source/outgoing/api_responses/020.php create mode 100644 user_guide_src/source/outgoing/api_transformers.rst create mode 100644 user_guide_src/source/outgoing/api_transformers/001.php create mode 100644 user_guide_src/source/outgoing/api_transformers/002.php create mode 100644 user_guide_src/source/outgoing/api_transformers/003.php create mode 100644 user_guide_src/source/outgoing/api_transformers/007.php create mode 100644 user_guide_src/source/outgoing/api_transformers/008.php create mode 100644 user_guide_src/source/outgoing/api_transformers/009.php create mode 100644 user_guide_src/source/outgoing/api_transformers/010.php create mode 100644 user_guide_src/source/outgoing/api_transformers/011.php create mode 100644 user_guide_src/source/outgoing/api_transformers/012.php create mode 100644 user_guide_src/source/outgoing/api_transformers/013.php create mode 100644 user_guide_src/source/outgoing/api_transformers/014.php create mode 100644 user_guide_src/source/outgoing/api_transformers/015.php create mode 100644 user_guide_src/source/outgoing/api_transformers/016.php create mode 100644 user_guide_src/source/outgoing/api_transformers/017.php create mode 100644 user_guide_src/source/outgoing/api_transformers/018.php create mode 100644 user_guide_src/source/outgoing/api_transformers/019.php create mode 100644 user_guide_src/source/outgoing/api_transformers/022.php create mode 100644 user_guide_src/source/outgoing/api_transformers/023.php diff --git a/loader.neon b/loader.neon new file mode 100644 index 000000000000..b01c551b4dc0 --- /dev/null +++ b/loader.neon @@ -0,0 +1,4 @@ +# total 5 errors + +includes: + - missingType.iterableValue.neon diff --git a/system/API/ApiException.php b/system/API/ApiException.php new file mode 100644 index 000000000000..9af9e31ca63c --- /dev/null +++ b/system/API/ApiException.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +use CodeIgniter\Exceptions\FrameworkException; + +/** + * Custom exception for API-related errors. + */ +final class ApiException extends FrameworkException +{ + /** + * Thrown when the fields requested in a URL are not valid. + */ + public static function forInvalidFields(string $field): self + { + return new self(lang('Api.invalidFields', [$field])); + } + + /** + * Thrown when the includes requested in a URL are not valid. + */ + public static function forInvalidIncludes(string $include): self + { + return new self(lang('Api.invalidIncludes', [$include])); + } + + /** + * Thrown when an include is requested, but the method to handle it + * does not exist on the model. + */ + public static function forMissingInclude(string $include): self + { + return new self(lang('Api.missingInclude', [$include])); + } + + /** + * Thrown when a transformer class cannot be found. + */ + public static function forTransformerNotFound(string $transformerClass): self + { + return new self(lang('Api.transformerNotFound', [$transformerClass])); + } + + /** + * Thrown when a transformer class does not implement TransformerInterface. + */ + public static function forInvalidTransformer(string $transformerClass): self + { + return new self(lang('Api.invalidTransformer', [$transformerClass])); + } +} diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php new file mode 100644 index 000000000000..7019741aee77 --- /dev/null +++ b/system/API/BaseTransformer.php @@ -0,0 +1,221 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +use CodeIgniter\HTTP\IncomingRequest; +use InvalidArgumentException; + +/** + * Base class for transforming resources into arrays. + * Fulfills common functionality of the TransformerInterface, + * and provides helper methods for conditional inclusion/exclusion of values. + * + * Supports the following query variables from the request: + * - fields: Comma-separated list of fields to include in the response + * (e.g., ?fields=id,name,email) + * If not provided, all fields from toArray() are included. + * - include: Comma-separated list of related resources to include + * (e.g., ?include=posts,comments) + * This looks for methods named `include{Resource}()` on the transformer, + * and calls them to get the related data, which are added as a new key to the output. + * + * Example: + * + * class UserTransformer extends BaseTransformer + * { + * public function toArray(mixed $resource): array + * { + * return [ + * 'id' => $resource['id'], + * 'name' => $resource['name'], + * 'email' => $resource['email'], + * 'created_at' => $resource['created_at'], + * 'updated_at' => $resource['updated_at'], + * ]; + * } + * + * protected function includePosts(): array + * { + * $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + * return (new PostTransformer())->transformMany($posts); + * } + * } + */ +abstract class BaseTransformer implements TransformerInterface +{ + /** + * @var list|null + */ + private ?array $fields = null; + + /** + * @var list|null + */ + private ?array $includes = null; + + protected mixed $resource = null; + + public function __construct( + private ?IncomingRequest $request = null, + ) { + $this->request = $request ?? request(); + + $fields = $this->request->getGet('fields'); + $this->fields = is_string($fields) + ? array_map('trim', explode(',', $fields)) + : $fields; + + $includes = $this->request->getGet('include'); + $this->includes = is_string($includes) + ? array_map('trim', explode(',', $includes)) + : $includes; + } + + /** + * Converts the resource to an array representation. + * This is overridden by child classes to define the + * API-safe resource representation. + * + * @param mixed $resource The resource being transformed + */ + abstract public function toArray(mixed $resource): array; + + /** + * Transforms the given resource into an array using + * the $this->toArray(). + */ + public function transform(array|object|null $resource = null): array + { + // Store the resource so include methods can access it + $this->resource = $resource; + + if ($resource === null) { + $data = $this->toArray(null); + } elseif (is_object($resource) && method_exists($resource, 'toArray')) { + $data = $this->toArray($resource->toArray()); + } else { + $data = $this->toArray((array) $resource); + } + + $data = $this->limitFields($data); + + return $this->insertIncludes($data); + } + + /** + * Transforms a collection of resources using $this->transform() on each item. + * + * If the request's 'fields' query variable is set, only those fields will be included + * in the transformed output. + */ + public function transformMany(array $resources): array + { + return array_map(fn ($resource): array => $this->transform($resource), $resources); + } + + /** + * Define which fields can be requested via the 'fields' query parameter. + * Override in child classes to restrict available fields. + * Return null to allow all fields from toArray(). + * + * @return list|null + */ + protected function getAllowedFields(): ?array + { + return null; + } + + /** + * Define which related resources can be included via the 'include' query parameter. + * Override in child classes to restrict available includes. + * Return null to allow all includes that have corresponding methods. + * Return an empty array to disable all includes. + * + * @return list|null + */ + protected function getAllowedIncludes(): ?array + { + return null; + } + + /** + * Limits the given data array to only the fields specified + * + * @param array $data + * + * @return array + * + * @throws InvalidArgumentException + */ + private function limitFields(array $data): array + { + if ($this->fields === null || $this->fields === []) { + return $data; + } + + $allowedFields = $this->getAllowedFields(); + + // If whitelist is defined, validate against it + if ($allowedFields !== null) { + $invalidFields = array_diff($this->fields, $allowedFields); + + if ($invalidFields !== []) { + throw ApiException::forInvalidFields(implode(', ', $invalidFields)); + } + } + + return array_intersect_key($data, array_flip($this->fields)); + } + + /** + * Checks the request for 'include' query variable, and if present, + * calls the corresponding include{Resource} methods to add related data. + * + * @param array $data + * + * @return array + */ + private function insertIncludes(array $data): array + { + if ($this->includes === null) { + return $data; + } + + $allowedIncludes = $this->getAllowedIncludes(); + + if ($allowedIncludes === []) { + return $data; // No includes allowed + } + + // If whitelist is defined, filter the requested includes + if ($allowedIncludes !== null) { + $invalidIncludes = array_diff($this->includes, $allowedIncludes); + + if ($invalidIncludes !== []) { + throw ApiException::forInvalidIncludes(implode(', ', $invalidIncludes)); + } + } + + foreach ($this->includes as $include) { + $method = 'include' . ucfirst($include); + if (method_exists($this, $method)) { + $data[$include] = $this->{$method}(); + } else { + throw ApiException::forMissingInclude($include); + } + } + + return $data; + } +} diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index cc04fa509fd9..0449e91bf07c 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -393,8 +393,10 @@ protected function setResponseFormat(?string $format = null) * 'next' => '/api/items?page=2&perPage=20', * ] * ] + * + * @param class-string|null $transformWith */ - protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface + protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?string $transformWith = null): ResponseInterface { try { assert($this->request instanceof IncomingRequest); @@ -426,6 +428,21 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res ]; } + // Transform data if a transformer is provided + if ($transformWith !== null) { + if (! class_exists($transformWith)) { + throw ApiException::forTransformerNotFound($transformWith); + } + + $transformer = new $transformWith($this->request); + + if (! $transformer instanceof TransformerInterface) { + throw ApiException::forInvalidTransformer($transformWith); + } + + $data = $transformer->transformMany($data); + } + $links = $this->buildLinks($meta); $this->response->setHeader('Link', $this->linkHeader($links)); @@ -436,6 +453,9 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res 'meta' => $meta, 'links' => $links, ]); + } catch (ApiException $e) { + // Re-throw ApiExceptions so they can be handled by the caller + throw $e; } catch (DatabaseException $e) { log_message('error', lang('RESTful.cannotPaginate') . ' ' . $e->getMessage()); diff --git a/system/API/TransformerInterface.php b/system/API/TransformerInterface.php new file mode 100644 index 000000000000..3d994251b302 --- /dev/null +++ b/system/API/TransformerInterface.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +/** + * Interface for transforming resources into arrays. + * + * This interface can be implemented by classes that need to transform + * data into a standardized array format, such as for API responses. + */ +interface TransformerInterface +{ + /** + * Converts the resource to an array representation. + * This is overridden by child classes to define specific fields. + * + * @param mixed $resource The resource being transformed + * + * @return array + */ + public function toArray(mixed $resource): array; + + /** + * Transforms the given resource into an array. + * + * @param array|object|null $resource + * + * @return array + */ + public function transform(array|object|null $resource): array; + + /** + * Transforms a collection of resources using $this->transform() on each item. + * + * @param array $resources + * + * @return array> + */ + public function transformMany(array $resources): array; +} diff --git a/system/Commands/Generators/TransformerGenerator.php b/system/Commands/Generators/TransformerGenerator.php new file mode 100644 index 000000000000..6e9143b5dc48 --- /dev/null +++ b/system/Commands/Generators/TransformerGenerator.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton transformer file. + */ +class TransformerGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:transformer'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new transformer file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:transformer [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The transformer class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserTransformer).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Transformer'; + $this->directory = 'Transformers'; + $this->template = 'transformer.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.transformer'; + $this->generateClass($params); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + return $this->parseTemplate($class); + } +} diff --git a/system/Commands/Generators/Views/transformer.tpl.php b/system/Commands/Generators/Views/transformer.tpl.php new file mode 100644 index 000000000000..f06b2e81b897 --- /dev/null +++ b/system/Commands/Generators/Views/transformer.tpl.php @@ -0,0 +1,22 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\API\BaseTransformer; + +class {class} extends BaseTransformer +{ + /** + * Transform the resource into an array. + * + * @param mixed $resource + * + * @return array + */ + public function toArray(mixed $resource): array + { + return [ + // Add your transformation logic here + ]; + } +} diff --git a/system/Language/en/Api.php b/system/Language/en/Api.php new file mode 100644 index 000000000000..69dd6fee6ab8 --- /dev/null +++ b/system/Language/en/Api.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// API language settings +return [ + 'invalidFields' => 'Invalid field requested: {0}', + 'invalidIncludes' => 'Invalid include requested: {0}', + 'missingInclude' => 'Missing include method for: {0}', + 'transformerNotFound' => 'Transformer class \'{0}\' not found.', + 'invalidTransformer' => 'Transformer class \'{0}\' must implement TransformerInterface.', +]; diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 247cd0158331..01e60c402955 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -19,18 +19,19 @@ 'generator' => [ 'cancelOperation' => 'Operation has been cancelled.', 'className' => [ - 'cell' => 'Cell class name', - 'command' => 'Command class name', - 'config' => 'Config class name', - 'controller' => 'Controller class name', - 'default' => 'Class name', - 'entity' => 'Entity class name', - 'filter' => 'Filter class name', - 'migration' => 'Migration class name', - 'model' => 'Model class name', - 'seeder' => 'Seeder class name', - 'test' => 'Test class name', - 'validation' => 'Validation class name', + 'cell' => 'Cell class name', + 'command' => 'Command class name', + 'config' => 'Config class name', + 'controller' => 'Controller class name', + 'default' => 'Class name', + 'entity' => 'Entity class name', + 'filter' => 'Filter class name', + 'migration' => 'Migration class name', + 'model' => 'Model class name', + 'seeder' => 'Seeder class name', + 'test' => 'Test class name', + 'transformer' => 'Transformer class name', + 'validation' => 'Validation class name', ], 'commandType' => 'Command type', 'databaseGroup' => 'Database group', diff --git a/tests/_support/API/InvalidTransformer.php b/tests/_support/API/InvalidTransformer.php new file mode 100644 index 000000000000..5e3c7e524778 --- /dev/null +++ b/tests/_support/API/InvalidTransformer.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\API; + +/** + * Invalid transformer for testing error handling + * Does not implement TransformerInterface + */ +class InvalidTransformer +{ + public function toArray(mixed $resource): array + { + return []; + } +} diff --git a/tests/_support/API/TestTransformer.php b/tests/_support/API/TestTransformer.php new file mode 100644 index 000000000000..ebed31568137 --- /dev/null +++ b/tests/_support/API/TestTransformer.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\API; + +use CodeIgniter\API\BaseTransformer; + +/** + * Test transformer for testing paginate() with transformers + */ +class TestTransformer extends BaseTransformer +{ + /** + * Transform the resource into an array. + * + * @return array + */ + public function toArray(mixed $resource): array + { + return [ + 'id' => $resource['id'] ?? null, + 'name' => $resource['name'] ?? null, + 'transformed' => true, + 'name_upper' => isset($resource['name']) ? strtoupper($resource['name']) : null, + ]; + } +} diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index cc8748ce1b4a..a0196189493d 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -36,6 +36,8 @@ use Exception; use PHPUnit\Framework\Attributes\Group; use stdClass; +use Tests\Support\API\InvalidTransformer; +use Tests\Support\API\TestTransformer; /** * @internal @@ -1053,4 +1055,162 @@ public function testPaginateSinglePage(): void $this->assertStringContainsString('page=1', (string) $responseBody['links']['first']); $this->assertStringContainsString('page=1', (string) $responseBody['links']['last']); } + + public function testPaginateWithTransformer(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 2, 1); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20, TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that data is transformed + $this->assertArrayHasKey('data', $responseBody); + $this->assertCount(2, $responseBody['data']); + + // Check first item is transformed + $this->assertArrayHasKey('transformed', $responseBody['data'][0]); + $this->assertTrue($responseBody['data'][0]['transformed']); + $this->assertArrayHasKey('name_upper', $responseBody['data'][0]); + $this->assertSame('ITEM 1', $responseBody['data'][0]['name_upper']); + + // Check second item is transformed + $this->assertArrayHasKey('transformed', $responseBody['data'][1]); + $this->assertTrue($responseBody['data'][1]['transformed']); + $this->assertArrayHasKey('name_upper', $responseBody['data'][1]); + $this->assertSame('ITEM 2', $responseBody['data'][1]['name_upper']); + + // Meta and links should still be present + $this->assertArrayHasKey('meta', $responseBody); + $this->assertArrayHasKey('links', $responseBody); + } + + public function testPaginateWithTransformerAndQueryBuilder(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + // Mock the database and builder + $db = $this->createMock(BaseConnection::class); + + $builder = $this->getMockBuilder(BaseBuilder::class) + ->setConstructorArgs(['test_table', $db]) + ->onlyMethods(['countAllResults', 'limit', 'get']) + ->getMock(); + + $result = $this->createMock(BaseResult::class); + $result->method('getResultArray')->willReturn($data); + + $builder->method('countAllResults')->willReturn(2); + $builder->method('limit')->willReturnSelf(); + $builder->method('get')->willReturn($result); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$builder, 20, TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that data is transformed + $this->assertArrayHasKey('data', $responseBody); + $this->assertCount(2, $responseBody['data']); + $this->assertTrue($responseBody['data'][0]['transformed']); + $this->assertSame('ITEM 1', $responseBody['data'][0]['name_upper']); + } + + public function testPaginateWithNonExistentTransformer(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 1, 1); + + $controller = $this->makeController('/api/items'); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.transformerNotFound', ['NonExistent\\Transformer'])); + + $this->invoke($controller, 'paginate', [$model, 20, 'NonExistent\\Transformer']); + } + + public function testPaginateWithInvalidTransformer(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 1, 1); + + $controller = $this->makeController('/api/items'); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.invalidTransformer', [InvalidTransformer::class])); + + $this->invoke($controller, 'paginate', [$model, 20, InvalidTransformer::class]); + } + + public function testPaginateWithTransformerPreservesMetaAndLinks(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ['id' => 3, 'name' => 'Item 3'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 2, 10, 5); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 2, TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check meta is correct + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(2, $responseBody['meta']['perPage']); + $this->assertSame(10, $responseBody['meta']['total']); + $this->assertSame(5, $responseBody['meta']['totalPages']); + + // Check links are present + $this->assertArrayHasKey('self', $responseBody['links']); + $this->assertArrayHasKey('first', $responseBody['links']); + $this->assertArrayHasKey('last', $responseBody['links']); + $this->assertArrayHasKey('next', $responseBody['links']); + $this->assertArrayHasKey('prev', $responseBody['links']); + + // Check headers + $this->assertSame('10', $this->response->getHeaderLine('X-Total-Count')); + $this->assertNotEmpty($this->response->getHeaderLine('Link')); + } + + public function testPaginateWithTransformerEmptyData(): void + { + $data = []; + + $model = $this->createMockModelWithPager($data, 1, 20, 0, 0); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20, TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that data is empty array + $this->assertArrayHasKey('data', $responseBody); + $this->assertSame([], $responseBody['data']); + + // Meta should show no results + $this->assertSame(0, $responseBody['meta']['total']); + $this->assertSame(0, $responseBody['meta']['totalPages']); + } } diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php new file mode 100644 index 000000000000..790fa4d70d43 --- /dev/null +++ b/tests/system/API/TransformerTest.php @@ -0,0 +1,597 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +use CodeIgniter\Entity\Entity; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use PHPUnit\Framework\Attributes\Group; +use stdClass; + +/** + * @internal + */ +#[Group('Others')] +final class TransformerTest extends CIUnitTestCase +{ + private function createMockRequest(string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + $uri = new SiteURI($config, 'http://example.com/test' . ($query !== '' ? '?' . $query : '')); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + + // Parse query string and set GET globals + if ($query !== '') { + parse_str($query, $get); + $request->setGlobal('get', $get); + } + + return $request; + } + + public function testConstructorWithNoRequest(): void + { + $transformer = new class () extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => 1, 'name' => 'Test']; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testConstructorWithRequest(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => 1, 'name' => 'Test']; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testTransformWithNull(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => 1, 'name' => 'Test']; + } + }; + + $result = $transformer->transform(); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testTransformWithEntity(): void + { + $request = $this->createMockRequest(); + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => 'Test Entity', + ]; + }; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($entity); + + $this->assertSame(['id' => 1, 'name' => 'Test Entity'], $result); + } + + public function testTransformWithArray(): void + { + $request = $this->createMockRequest(); + $data = ['id' => 1, 'name' => 'Test Array']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test Array'], $result); + } + + public function testTransformWithObject(): void + { + $request = $this->createMockRequest(); + $object = new stdClass(); + $object->id = 1; + $object->name = 'Test Object'; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($object); + + $this->assertSame(['id' => 1, 'name' => 'Test Object'], $result); + } + + public function testTransformMany(): void + { + $request = $this->createMockRequest(); + $data = [ + ['id' => 1, 'name' => 'First'], + ['id' => 2, 'name' => 'Second'], + ['id' => 3, 'name' => 'Third'], + ]; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transformMany($data); + + $this->assertCount(3, $result); + $this->assertSame(['id' => 1, 'name' => 'First'], $result[0]); + $this->assertSame(['id' => 2, 'name' => 'Second'], $result[1]); + $this->assertSame(['id' => 3, 'name' => 'Third'], $result[2]); + } + + public function testTransformManyWithEmptyArray(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource ?? []; + } + }; + + $result = $transformer->transformMany([]); + + $this->assertSame([], $result); + } + + public function testLimitFieldsWithNoFieldsParam(): void + { + $request = $this->createMockRequest(); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test', 'email' => 'test@example.com'], $result); + } + + public function testLimitFieldsWithFieldsParam(): void + { + $request = $this->createMockRequest('fields=id,name'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testLimitFieldsWithSingleField(): void + { + $request = $this->createMockRequest('fields=name'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['name' => 'Test'], $result); + } + + public function testLimitFieldsWithSpaces(): void + { + $request = $this->createMockRequest('fields=id, name, email'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com', 'bio' => 'Bio']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test', 'email' => 'test@example.com'], $result); + } + + public function testLimitFieldsWithAllowedFieldsValidation(): void + { + $request = $this->createMockRequest('fields=id,name'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function getAllowedFields(): array + { + return ['id', 'name', 'email']; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testLimitFieldsThrowsExceptionForInvalidField(): void + { + $this->expectException(ApiException::class); + + $request = $this->createMockRequest('fields=id,password'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function getAllowedFields(): array + { + return ['id', 'name', 'email']; + } + }; + + $transformer->transform($data); + } + + public function testInsertIncludesWithNoIncludeParam(): void + { + $request = $this->createMockRequest(); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + $this->assertArrayNotHasKey('posts', $result); + } + + public function testInsertIncludesWithIncludeParam(): void + { + $request = $this->createMockRequest('include=posts'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame([ + 'id' => 1, + 'name' => 'Test', + 'posts' => [['id' => 1, 'title' => 'Post 1']], + ], $result); + } + + public function testInsertIncludesWithMultipleIncludes(): void + { + $request = $this->createMockRequest('include=posts,comments'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + + protected function includeComments(): array + { + return [['id' => 1, 'text' => 'Comment 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame([ + 'id' => 1, + 'name' => 'Test', + 'posts' => [['id' => 1, 'title' => 'Post 1']], + 'comments' => [['id' => 1, 'text' => 'Comment 1']], + ], $result); + } + + public function testInsertIncludesThrowsExceptionForNonExistentMethod(): void + { + $request = $this->createMockRequest('include=posts,nonexistent'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['nonexistent'])); + + $transformer->transform($data); + } + + public function testInsertIncludesWithEmptyAllowedIncludes(): void + { + $request = $this->createMockRequest('include=posts'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function getAllowedIncludes(): array + { + return []; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + $this->assertArrayNotHasKey('posts', $result); + } + + public function testCombinedFieldsAndIncludes(): void + { + $request = $this->createMockRequest('fields=id,name&include=posts'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame([ + 'id' => 1, + 'name' => 'Test', + 'posts' => [['id' => 1, 'title' => 'Post 1']], + ], $result); + $this->assertArrayNotHasKey('email', $result); + } + + public function testTransformManyWithFieldsFilter(): void + { + $request = $this->createMockRequest('fields=id,name'); + $data = [ + ['id' => 1, 'name' => 'First', 'email' => 'first@example.com'], + ['id' => 2, 'name' => 'Second', 'email' => 'second@example.com'], + ]; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transformMany($data); + + $this->assertCount(2, $result); + $this->assertSame(['id' => 1, 'name' => 'First'], $result[0]); + $this->assertSame(['id' => 2, 'name' => 'Second'], $result[1]); + } + + public function testTransformManyWithIncludes(): void + { + $request = $this->createMockRequest('include=posts'); + $data = [ + ['id' => 1, 'name' => 'First'], + ['id' => 2, 'name' => 'Second'], + ]; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transformMany($data); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('posts', $result[0]); + $this->assertArrayHasKey('posts', $result[1]); + } + + public function testTransformThrowsExceptionForInvalidInclude(): void + { + $request = $this->createMockRequest('include=nonexistent'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['nonexistent'])); + + $data = ['id' => 1, 'name' => 'Test']; + $transformer->transform($data); + } + + public function testTransformThrowsExceptionForMissingIncludeMethod(): void + { + $request = $this->createMockRequest('include=invalid'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['invalid'])); + + $data = ['id' => 1, 'name' => 'Test']; + $transformer->transform($data); + } + + public function testTransformWithMultipleIncludesValidatesAll(): void + { + $request = $this->createMockRequest('include=posts,invalid'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['invalid'])); + + $data = ['id' => 1, 'name' => 'Test']; + $transformer->transform($data); + } + + public function testTransformWithValidIncludeDoesNotThrowException(): void + { + $request = $this->createMockRequest('include=posts'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $data = ['id' => 1, 'name' => 'Test']; + $result = $transformer->transform($data); + + $this->assertArrayHasKey('posts', $result); + $this->assertSame([['id' => 1, 'title' => 'Post 1']], $result['posts']); + } +} diff --git a/tests/system/Commands/TransformerGeneratorTest.php b/tests/system/Commands/TransformerGeneratorTest.php new file mode 100644 index 000000000000..a37a29099eaa --- /dev/null +++ b/tests/system/Commands/TransformerGeneratorTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class TransformerGeneratorTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + protected function tearDown(): void + { + $result = str_replace(["\033[0;32m", "\033[0m", "\n"], '', $this->getStreamFilterBuffer()); + $file = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, trim(substr($result, 14))); + if (is_file($file)) { + unlink($file); + } + } + + protected function getFileContents(string $filepath): string + { + if (! is_file($filepath)) { + return ''; + } + + $contents = file_get_contents($filepath); + + return $contents !== false ? $contents : ''; + } + + public function testGenerateTransformer(): void + { + command('make:transformer user'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/User.php'; + $this->assertFileExists($file); + $contents = $this->getFileContents($file); + $this->assertStringContainsString('extends BaseTransformer', $contents); + $this->assertStringContainsString('namespace App\Transformers', $contents); + $this->assertStringContainsString('public function toArray(mixed $resource): array', $contents); + } + + public function testGenerateTransformerWithSubdirectory(): void + { + command('make:transformer api/v1/product'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/Api/V1/Product.php'; + $this->assertFileExists($file); + $contents = $this->getFileContents($file); + $this->assertStringContainsString('namespace App\Transformers\Api\V1', $contents); + $this->assertStringContainsString('class Product extends BaseTransformer', $contents); + } + + public function testGenerateTransformerWithOptionSuffix(): void + { + command('make:transformer order -suffix'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/OrderTransformer.php'; + $this->assertFileExists($file); + $contents = $this->getFileContents($file); + $this->assertStringContainsString('class OrderTransformer extends BaseTransformer', $contents); + } + + public function testGenerateTransformerWithOptionForce(): void + { + // Create the file first + command('make:transformer customer'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/Customer.php'; + $this->assertFileExists($file); + + // Try to overwrite without force + $this->resetStreamFilterBuffer(); + command('make:transformer customer'); + $this->assertStringContainsString('File exists: ', $this->getStreamFilterBuffer()); + + // Now overwrite with force + $this->resetStreamFilterBuffer(); + command('make:transformer customer -force'); + $this->assertStringContainsString('File overwritten: ', $this->getStreamFilterBuffer()); + $this->assertFileExists($file); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index bd93171be60e..565505b9b18e 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -73,6 +73,7 @@ Enhancements Libraries ========= +- **API Transformers:** This new feature provides a structured way to transform data for API responses. See :ref:`API Transformers ` for details. - **CLI:** Added ``SignalTrait`` to provide unified handling of operating system signals in CLI commands. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index 86f577e7d064..e2710411069d 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -251,6 +251,27 @@ Options: * ``--namespace``: Set the root namespace. Defaults to value of ``Tests``. * ``--force``: Set this flag to overwrite existing files on destination. +make:transformer +---------------- + +Creates a new API transformer file. + +Usage: +====== +:: + + make:transformer [options] + +Argument: +========= +* ``name``: The name of the transformer class. **[REQUIRED]** + +Options: +======== +* ``--namespace``: Set the root namespace. Defaults to value of ``APP_NAMESPACE``. +* ``--suffix``: Append the component suffix to the generated class name. +* ``--force``: Set this flag to overwrite existing files on destination. + make:migration -------------- diff --git a/user_guide_src/source/outgoing/api_responses.rst b/user_guide_src/source/outgoing/api_responses.rst index 645951cf4347..e1c21e6dac08 100644 --- a/user_guide_src/source/outgoing/api_responses.rst +++ b/user_guide_src/source/outgoing/api_responses.rst @@ -312,12 +312,22 @@ name and any necessary joins or where clauses. Class Reference *************** -.. php:method:: paginate(Model|BaseBuilder $resource, int $perPage = 20) +.. php:method:: paginate(Model|BaseBuilder $resource, int $perPage = 20, ?string $transformWith = null) :param Model|BaseBuilder $resource: The resource to paginate, either a Model or a Builder instance. :param int $perPage: The number of items to return per page. + :param string|null $transformWith: Optional transformer class name to transform the results. Generates a paginated response from the given resource. The resource can be either a Model or a Builder instance. The method will automatically determine the current page from the request's query parameters. The response will include the paginated data, along with metadata about the pagination state and links to navigate through the pages. + + If you provide a ``$transformWith`` parameter with a transformer class name, each item in the paginated + results will be transformed using that transformer before being returned. This is useful for controlling + the structure and content of your API responses. See :ref:`API Transformers ` for more + information on creating and using transformers. + + Example with transformer: + + .. literalinclude:: api_responses/020.php diff --git a/user_guide_src/source/outgoing/api_responses/020.php b/user_guide_src/source/outgoing/api_responses/020.php new file mode 100644 index 000000000000..e54e8cc69fff --- /dev/null +++ b/user_guide_src/source/outgoing/api_responses/020.php @@ -0,0 +1,18 @@ +paginate(resource: $model, perPage: 20, transformWith: UserTransformer::class); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst new file mode 100644 index 000000000000..e462b588c159 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -0,0 +1,358 @@ +.. _api_transformers: + +############# +API Resources +############# + +When building APIs, you often need to transform your data models into a consistent format before sending +them to the client. API Resources, implemented through transformers, provide a clean way to convert your +entities, arrays, or objects into structured API responses. They help separate your internal data structure +from what you expose through your API, making it easier to maintain and evolve your API over time. + +.. contents:: + :local: + :depth: 2 + +***************** +Quick Example +***************** + +The following example shows a common usage pattern for transformers in your application. + +.. literalinclude:: api_transformers/001.php + +In this example, the ``UserTransformer`` defines which fields from a user entity should be included in the +API response. The ``transform()`` method converts a single resource, while ``transformMany()`` handles +collections of resources. + +********************** +Creating a Transformer +********************** + +To create a transformer, extend the ``BaseTransformer`` class and implement the ``toArray()`` method to +define your API resource structure. The ``toArray()`` method receives the resource being transformed as +a parameter, allowing you to access and transform its data. + +Basic Transformer +================= + +.. literalinclude:: api_transformers/002.php + +The ``toArray()`` method receives the resource (entity, array, or object) as its parameter and defines +the structure of your API response. You can include any fields you want from the resource, and you can +also rename or transform values as needed. + +Generating Transformer Files +============================= + +CodeIgniter provides a CLI command to quickly generate transformer skeleton files: + +.. code-block:: console + + php spark make:transformer User + +This creates a new transformer file at **app/Transformers/User.php** with the basic structure already in place. + +Command Options +--------------- + +The ``make:transformer`` command supports several options: + +**--suffix** + Appends "Transformer" to the class name: + + .. code-block:: console + + php spark make:transformer User --suffix + + Creates **app/Transformers/UserTransformer.php** + +**--namespace** + Specifies a custom root namespace: + + .. code-block:: console + + php spark make:transformer User --namespace="MyCompany\\API" + +**--force** + Forces overwriting an existing file: + + .. code-block:: console + + php spark make:transformer User --force + +Subdirectories +-------------- + +You can organize transformers into subdirectories by including the path in the name: + +.. code-block:: console + + php spark make:transformer api/v1/User + +This creates **app/Transformers/Api/V1/User.php** with the appropriate namespace ``App\Transformers\Api\V1``. + +Using Transformers in Controllers +================================== + +Once you've created a transformer, you can use it in your controllers to transform data before returning +it to the client. + +.. literalinclude:: api_transformers/003.php + +*********************** +Field Filtering +*********************** + +The transformer automatically supports field filtering through the ``fields`` query parameter of the current URL. +This allows API clients to request only specific fields they need, reducing bandwidth and improving performance. + +.. literalinclude:: api_transformers/007.php + +A request to ``/users/1?fields=id,name`` would return only: + +.. code-block:: json + + { + "id": 1, + "name": "John Doe" + } + +Restricting Available Fields +============================= + +By default, clients can request any field defined in your ``toArray()`` method. You can restrict which +fields are allowed by overriding the ``getAllowedFields()`` method: + +.. literalinclude:: api_transformers/008.php + +Now, even if a client requests ``/users/1?fields=email``, an ``ApiException`` will be thrown because +``email`` is not in the allowed fields list. + +*************************** +Including Related Resources +*************************** + +Transformers support loading of related resources through the ``include`` query parameter. This +follows a common API pattern where clients can specify which relationships they want included. +While relationships are the most frequent use case, you can include any additional data +you want by defining custom include methods. + +Defining Include Methods +========================= + +To support including related resources, create methods prefixed with ``include`` followed by the +resource name. Inside these methods, you can access the current resource being transformed via +``$this->resource``: + +.. literalinclude:: api_transformers/009.php + +Note how the include methods use ``$this->resource['id']`` to access the ID of the user being transformed. +The ``$this->resource`` property is automatically set by the transformer when ``transform()`` is called. + +Clients can now request: ``/users/1?include=posts,comments`` + +The response would include: + +.. code-block:: json + + { + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "posts": [ + { + "id": 1, + "title": "First Post" + } + ], + "comments": [ + { + "id": 1, + "content": "Great article!" + } + ] + } + +Restricting Available Includes +=============================== + +Similar to field filtering, you can restrict which relationships can be included by overriding the +``getAllowedIncludes()`` method: + +.. literalinclude:: api_transformers/010.php + +If you want to disable all includes, return an empty array: + +.. literalinclude:: api_transformers/011.php + +Include Validation +================== + +The transformer automatically validates that any requested includes have corresponding ``include*()`` methods +defined in your transformer class. If a client requests an include that doesn't exist, an ``ApiException`` +will be thrown. + +For example, if a client requests:: + + GET /api/users?include=invalid + +And your transformer doesn't have an ``includeInvalid()`` method, an exception will be thrown with the message: +"Missing include method for: invalid". + +This helps catch typos and prevents unexpected behavior. + +************************ +Transforming Collections +************************ + +The ``transformMany()`` method makes it easy to transform arrays of resources: + +.. literalinclude:: api_transformers/012.php + +The ``transformMany()`` method applies the same transformation logic to each item in the collection, +including any field filtering or includes specified in the request. + +********************************* +Working with Different Data Types +********************************* + +Transformers can handle various data types, not just entities. + +Transforming Entities +===================== + +When you pass an ``Entity`` instance to ``transform()``, it automatically calls the entity's ``toArray()`` +method to get the data: + +.. literalinclude:: api_transformers/013.php + +Transforming Arrays +=================== + +You can transform plain arrays as well: + +.. literalinclude:: api_transformers/014.php + +Transforming Objects +==================== + +Any object can be cast to an array and transformed: + +.. literalinclude:: api_transformers/015.php + +Using toArray() Only +==================== + +If you don't pass a resource to ``transform()``, it will use the data from your ``toArray()`` method: + +.. literalinclude:: api_transformers/016.php + +*************** +Class Reference +*************** + +.. php:namespace:: CodeIgniter\API + +.. php:class:: BaseTransformer + + .. php:method:: __construct(?IncomingRequest $request = null) + + :param IncomingRequest|null $request: Optional request instance. If not provided, the global request will be used. + + Initializes the transformer and extracts the ``fields`` and ``include`` query parameters from the request. + + .. php:method:: toArray(mixed $resource) + + :param mixed $resource: The resource being transformed (Entity, array, object, or null) + :returns: The array representation of the resource + :rtype: array + + This abstract method must be implemented by child classes to define the structure of the API resource. + The resource parameter contains the data being transformed. Return an array with the fields you want + to include in the API response, accessing data from the ``$resource`` parameter. + + .. literalinclude:: api_transformers/017.php + + .. php:method:: transform($resource = null) + + :param mixed $resource: The resource to transform (Entity, array, object, or null) + :returns: The transformed array + :rtype: array + + Transforms the given resource into an array by calling ``toArray()`` with the resource data. + If ``$resource`` is ``null``, passes ``null`` to ``toArray()``. + If it's an Entity, extracts its array representation first. Otherwise, casts it to an array. + + The resource is also stored in ``$this->resource`` so include methods can access it. + + The method automatically applies field filtering and includes based on query parameters. + + .. literalinclude:: api_transformers/018.php + + .. php:method:: transformMany(array $resources) + + :param array $resources: The array of resources to transform + :returns: Array of transformed resources + :rtype: array + + Transforms a collection of resources by calling ``transform()`` on each item. Field filtering and + includes are applied consistently to all items. + + .. literalinclude:: api_transformers/019.php + + .. php:method:: getAllowedFields() + + :returns: Array of allowed field names, or ``null`` to allow all fields + :rtype: array|null + + Override this method to restrict which fields can be requested via the ``fields`` query parameter. + Return ``null`` (the default) to allow all fields from ``toArray()``. Return an array of field names + to create a whitelist of allowed fields. + + .. literalinclude:: api_transformers/022.php + + .. php:method:: getAllowedIncludes() + + :returns: Array of allowed include names, or ``null`` to allow all includes + :rtype: array|null + + Override this method to restrict which related resources can be included via the ``include`` query + parameter. Return ``null`` (the default) to allow all includes that have corresponding methods. + Return an array of include names to create a whitelist. Return an empty array to disable all includes. + + .. literalinclude:: api_transformers/023.php + +******************* +Exception Reference +******************* + +.. php:class:: ApiException + + .. php:staticmethod:: forInvalidFields(string $field) + + :param string $field: The invalid field name(s) + :returns: ApiException instance + :rtype: ApiException + + Thrown when a client requests a field via the ``fields`` query parameter that is not in the allowed + fields list. + + .. php:staticmethod:: forInvalidIncludes(string $include) + + :param string $include: The invalid include name(s) + :returns: ApiException instance + :rtype: ApiException + + Thrown when a client requests an include via the ``include`` query parameter that is not in the + allowed includes list. + + .. php:staticmethod:: forMissingInclude(string $include) + + :param string $include: The missing include method name + :returns: ApiException instance + :rtype: ApiException + + Thrown when a client requests an include via the ``include`` query parameter, but the corresponding + ``include*()`` method does not exist in the transformer class. This validation ensures that all + requested includes have proper handler methods defined. diff --git a/user_guide_src/source/outgoing/api_transformers/001.php b/user_guide_src/source/outgoing/api_transformers/001.php new file mode 100644 index 000000000000..b3a7bb0ad9bf --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/001.php @@ -0,0 +1,24 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + ]; + } +} + +// In your controller +$user = model('UserModel')->find(1); +$transformer = new UserTransformer(); + +return $this->respond($transformer->transform($user)); diff --git a/user_guide_src/source/outgoing/api_transformers/002.php b/user_guide_src/source/outgoing/api_transformers/002.php new file mode 100644 index 000000000000..1980e4951ca3 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/002.php @@ -0,0 +1,18 @@ + $resource['id'], + 'username' => $resource['name'], // Renaming the field + 'email' => $resource['email'], + 'member_since' => date('Y-m-d', strtotime($resource['created_at'])), // Formatting + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/003.php b/user_guide_src/source/outgoing/api_transformers/003.php new file mode 100644 index 000000000000..8f4616091c64 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/003.php @@ -0,0 +1,33 @@ +find($id); + + if (! $user) { + return $this->failNotFound('User not found'); + } + + $transformer = new UserTransformer(); + + return $this->respond($transformer->transform($user)); + } + + public function index() + { + $users = model('UserModel')->findAll(); + + $transformer = new UserTransformer(); + + return $this->respond($transformer->transformMany($users)); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/007.php b/user_guide_src/source/outgoing/api_transformers/007.php new file mode 100644 index 000000000000..9a2f436d7c41 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/007.php @@ -0,0 +1,22 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + 'updated_at' => $resource['updated_at'], + ]; + } +} + +// Request: GET /users/1?fields=id,name +// Response: {"id": 1, "name": "John Doe"} diff --git a/user_guide_src/source/outgoing/api_transformers/008.php b/user_guide_src/source/outgoing/api_transformers/008.php new file mode 100644 index 000000000000..776a090d3ce3 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/008.php @@ -0,0 +1,25 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + 'updated_at' => $resource['updated_at'], + ]; + } + + protected function getAllowedFields(): ?array + { + // Only these fields can be requested + return ['id', 'name', 'created_at']; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/009.php b/user_guide_src/source/outgoing/api_transformers/009.php new file mode 100644 index 000000000000..f1aa4624ad87 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/009.php @@ -0,0 +1,32 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function includePosts(): array + { + // Use $this->resource to access the current resource being transformed + $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new PostTransformer())->transformMany($posts); + } + + protected function includeComments(): array + { + $comments = model('CommentModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new CommentTransformer())->transformMany($comments); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/010.php b/user_guide_src/source/outgoing/api_transformers/010.php new file mode 100644 index 000000000000..4a5538088fd9 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/010.php @@ -0,0 +1,48 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function getAllowedIncludes(): ?array + { + // Only these relationships can be included + return ['posts', 'comments']; + } + + protected function includePosts(): array + { + $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new PostTransformer())->transformMany($posts); + } + + protected function includeComments(): array + { + $comments = model('CommentModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new CommentTransformer())->transformMany($comments); + } + + protected function includeOrders(): array + { + // This method exists but won't be callable from the API + // because 'orders' is not in getAllowedIncludes() + $orders = model('OrderModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new OrderTransformer())->transformMany($orders); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/011.php b/user_guide_src/source/outgoing/api_transformers/011.php new file mode 100644 index 000000000000..7d4d53ea2b30 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/011.php @@ -0,0 +1,23 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function getAllowedIncludes(): ?array + { + // Return empty array to disable all includes + return []; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/012.php b/user_guide_src/source/outgoing/api_transformers/012.php new file mode 100644 index 000000000000..f3f3313c9cd1 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/012.php @@ -0,0 +1,21 @@ +findAll(); + + $transformer = new UserTransformer(); + $data = $transformer->transformMany($users); + + return $this->respond($data); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/013.php b/user_guide_src/source/outgoing/api_transformers/013.php new file mode 100644 index 000000000000..b54fc29fd156 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/013.php @@ -0,0 +1,13 @@ + 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', +]); + +$transformer = new UserTransformer(); +$result = $transformer->transform($user); diff --git a/user_guide_src/source/outgoing/api_transformers/014.php b/user_guide_src/source/outgoing/api_transformers/014.php new file mode 100644 index 000000000000..f47e9056d753 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/014.php @@ -0,0 +1,12 @@ + 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', +]; + +$transformer = new UserTransformer(); +$result = $transformer->transform($userData); diff --git a/user_guide_src/source/outgoing/api_transformers/015.php b/user_guide_src/source/outgoing/api_transformers/015.php new file mode 100644 index 000000000000..0c0df79ad350 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/015.php @@ -0,0 +1,11 @@ +id = 1; +$user->name = 'John Doe'; +$user->email = 'john@example.com'; + +$transformer = new UserTransformer(); +$result = $transformer->transform($user); diff --git a/user_guide_src/source/outgoing/api_transformers/016.php b/user_guide_src/source/outgoing/api_transformers/016.php new file mode 100644 index 000000000000..2436acc7d5de --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/016.php @@ -0,0 +1,21 @@ + '1.0', + 'status' => 'active', + 'message' => 'API is running', + ]; + } +} + +// Usage +$transformer = new StaticDataTransformer(); +$result = $transformer->transform(null); // No resource passed diff --git a/user_guide_src/source/outgoing/api_transformers/017.php b/user_guide_src/source/outgoing/api_transformers/017.php new file mode 100644 index 000000000000..4e856838feec --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/017.php @@ -0,0 +1,19 @@ + $resource['id'], + 'name' => $resource['name'], + 'price' => $resource['price'], + 'in_stock' => $resource['stock_quantity'] > 0, + 'description' => $resource['description'], + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/018.php b/user_guide_src/source/outgoing/api_transformers/018.php new file mode 100644 index 000000000000..a8580b7dbe0a --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/018.php @@ -0,0 +1,17 @@ +find(1); + +$transformer = new UserTransformer(); + +// Transform an entity +$result = $transformer->transform($user); + +// Transform an array +$userData = ['id' => 1, 'name' => 'John Doe']; +$result = $transformer->transform($userData); + +// Use toArray() data +$result = $transformer->transform(); diff --git a/user_guide_src/source/outgoing/api_transformers/019.php b/user_guide_src/source/outgoing/api_transformers/019.php new file mode 100644 index 000000000000..bb847b852397 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/019.php @@ -0,0 +1,13 @@ +findAll(); + +$transformer = new UserTransformer(); +$results = $transformer->transformMany($users); + +// $results is an array of transformed user arrays +foreach ($results as $user) { + // Each $user is the result of calling transform() on an individual user +} diff --git a/user_guide_src/source/outgoing/api_transformers/022.php b/user_guide_src/source/outgoing/api_transformers/022.php new file mode 100644 index 000000000000..f22631c56d70 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/022.php @@ -0,0 +1,25 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + ]; + } + + protected function getAllowedFields(): ?array + { + // Clients can only request id, name, and created_at + // Attempting to request 'email' will throw an ApiException + return ['id', 'name', 'created_at']; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/023.php b/user_guide_src/source/outgoing/api_transformers/023.php new file mode 100644 index 000000000000..8b0cb071a924 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/023.php @@ -0,0 +1,39 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function getAllowedIncludes(): ?array + { + // Only 'posts' can be included via ?include=posts + // Attempting to include 'orders' will throw an ApiException + return ['posts']; + } + + protected function includePosts(): array + { + $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new PostTransformer())->transformMany($posts); + } + + protected function includeOrders(): array + { + // This method exists but cannot be called via the API + $orders = model('OrderModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new OrderTransformer())->transformMany($orders); + } +} diff --git a/user_guide_src/source/outgoing/index.rst b/user_guide_src/source/outgoing/index.rst index 7b643ae5aad7..3468043f229d 100644 --- a/user_guide_src/source/outgoing/index.rst +++ b/user_guide_src/source/outgoing/index.rst @@ -16,6 +16,7 @@ View components are used to build what is returned to the user. table response api_responses + api_transformers csp localization alternative_php diff --git a/utils/phpstan-baseline/function.alreadyNarrowedType.neon b/utils/phpstan-baseline/function.alreadyNarrowedType.neon index 7f6d5a1b1fdd..e39d8b2c49fc 100644 --- a/utils/phpstan-baseline/function.alreadyNarrowedType.neon +++ b/utils/phpstan-baseline/function.alreadyNarrowedType.neon @@ -3,11 +3,11 @@ parameters: ignoreErrors: - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:188\) and ''stringAsHtml'' will always evaluate to true\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:190\) and ''stringAsHtml'' will always evaluate to true\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:308\) and ''stringAsHtml'' will always evaluate to true\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:310\) and ''stringAsHtml'' will always evaluate to true\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/function.impossibleType.neon b/utils/phpstan-baseline/function.impossibleType.neon index 431a49bc5aed..43296fa89d08 100644 --- a/utils/phpstan-baseline/function.impossibleType.neon +++ b/utils/phpstan-baseline/function.impossibleType.neon @@ -8,11 +8,11 @@ parameters: path: ../../system/Debug/ExceptionHandler.php - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:130\) and ''stringAsHtml'' will always evaluate to false\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:132\) and ''stringAsHtml'' will always evaluate to false\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:628\) and ''stringAsHtml'' will always evaluate to false\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:630\) and ''stringAsHtml'' will always evaluate to false\.$#' count: 1 path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 07dad94ede7f..3832d2bb7472 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2766 errors +# total 2776 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 964c022fc992..7a467812bf34 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1361 errors +# total 1371 errors parameters: ignoreErrors: @@ -5227,6 +5227,56 @@ parameters: count: 1 path: ../../system/View/Parser.php + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:313\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:336\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:362\:\:includeComments\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:362\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:394\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:417\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:445\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:497\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:556\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:579\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + - message: '#^Method CodeIgniter\\AutoReview\\ComposerJsonTest\:\:checkConfig\(\) has parameter \$fromComponent with no value type specified in iterable type array\.$#' count: 1 From 6a64c4cf3bd94847a34e90e6f69a5359d15845e2 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Thu, 23 Oct 2025 15:55:38 +0200 Subject: [PATCH 38/84] fix: controller attribute filters with parameters (#9769) * fix: controller attribute filters with parameters * fix test --- system/Router/Attributes/Filter.php | 2 +- .../Controllers/AttributeController.php | 11 +++++++ .../Router/Filters/TestAttributeFilter.php | 10 +++++-- tests/system/CodeIgniterTest.php | 28 +++++++++++++++++ tests/system/Router/Attributes/FilterTest.php | 30 +++++++++---------- .../source/incoming/controller_attributes.rst | 6 +++- 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/system/Router/Attributes/Filter.php b/system/Router/Attributes/Filter.php index 33794e1711bf..130d2946f0dc 100644 --- a/system/Router/Attributes/Filter.php +++ b/system/Router/Attributes/Filter.php @@ -62,6 +62,6 @@ public function getFilters(): array return [$this->by]; } - return [$this->by => $this->having]; + return [$this->by . ':' . implode(',', $this->having)]; } } diff --git a/tests/_support/Router/Controllers/AttributeController.php b/tests/_support/Router/Controllers/AttributeController.php index 0c43404a2a8e..c0421ab9d601 100644 --- a/tests/_support/Router/Controllers/AttributeController.php +++ b/tests/_support/Router/Controllers/AttributeController.php @@ -41,6 +41,17 @@ public function filtered(): ResponseInterface return $this->response->setBody('Filtered: ' . $body); } + /** + * Test method with Filter attribute with parameters + */ + #[Filter(by: 'testAttributeFilter', having: ['arg1', 'arg2'])] + public function filteredWithParams(): ResponseInterface + { + $body = $this->request->getBody(); + + return $this->response->setBody('Filtered: ' . $body); + } + /** * Test method with Restrict attribute (environment) */ diff --git a/tests/_support/Router/Filters/TestAttributeFilter.php b/tests/_support/Router/Filters/TestAttributeFilter.php index dbafc230ec3e..a77ef72c95a7 100644 --- a/tests/_support/Router/Filters/TestAttributeFilter.php +++ b/tests/_support/Router/Filters/TestAttributeFilter.php @@ -21,17 +21,23 @@ class TestAttributeFilter implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { + if ($arguments !== null) { + $arguments = '(' . implode(',', $arguments) . ')'; + } // Modify request body to indicate filter ran - $request->setBody('before_filter_ran:'); + $request->setBody('before_filter_ran' . $arguments . ':'); return $request; } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { + if ($arguments !== null) { + $arguments = '(' . implode(',', $arguments) . ')'; + } // Append to response body to indicate filter ran $body = $response->getBody(); - $response->setBody($body . ':after_filter_ran'); + $response->setBody($body . ':after_filter_ran' . $arguments); return $response; } diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index e3acab514cea..05e20cd7b361 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -1062,6 +1062,34 @@ public function testRouteAttributeFilterIntegration(): void $this->assertStringContainsString(':after_filter_ran', (string) $output); } + public function testRouteAttributeFilterWithParamsIntegration(): void + { + $_SERVER['argv'] = ['index.php', 'attribute/filteredWithParams']; + $_SERVER['argc'] = 2; + + Services::superglobals()->setServer('REQUEST_URI', '/attribute/filteredWithParams'); + Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + + // Register the test filter + $filterConfig = config('Filters'); + $filterConfig->aliases['testAttributeFilter'] = TestAttributeFilter::class; + service('filters', $filterConfig); + + // Inject mock router + $routes = service('routes'); + $routes->get('attribute/filteredWithParams', '\Tests\Support\Router\Controllers\AttributeController::filteredWithParams'); + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + ob_start(); + $this->codeigniter->run(); + $output = ob_get_clean(); + + // Verify filter ran before (modified request body) and after (appended to response) + $this->assertStringContainsString('Filtered: before_filter_ran(arg1,arg2):', (string) $output); + $this->assertStringContainsString(':after_filter_ran(arg1,arg2)', (string) $output); + } + public function testRouteAttributeRestrictIntegration(): void { $_SERVER['argv'] = ['index.php', 'attribute/restricted']; diff --git a/tests/system/Router/Attributes/FilterTest.php b/tests/system/Router/Attributes/FilterTest.php index 8a230c805287..33ee3ca89d19 100644 --- a/tests/system/Router/Attributes/FilterTest.php +++ b/tests/system/Router/Attributes/FilterTest.php @@ -90,8 +90,7 @@ public function testGetFiltersReturnsArrayWithFilterNameAndArguments(): void $filters = $filter->getFilters(); $this->assertCount(1, $filters); - $this->assertArrayHasKey('auth', $filters); - $this->assertSame(['admin'], $filters['auth']); + $this->assertSame('auth:admin', $filters[0]); } public function testGetFiltersReturnsArrayWithMultipleArguments(): void @@ -101,8 +100,7 @@ public function testGetFiltersReturnsArrayWithMultipleArguments(): void $filters = $filter->getFilters(); $this->assertCount(1, $filters); - $this->assertArrayHasKey('permission', $filters); - $this->assertSame(['posts.edit', 'posts.delete'], $filters['permission']); + $this->assertSame('permission:posts.edit,posts.delete', $filters[0]); } public function testGetFiltersWithEmptyHavingReturnsSimpleArray(): void @@ -135,13 +133,13 @@ public function testGetFiltersFormatIsConsistentAcrossInstances(): void $filters1 = $filterWithoutArgs->getFilters(); $filters2 = $filterWithArgs->getFilters(); - // Without args: simple array - $this->assertArrayNotHasKey('filter1', $filters1); - $this->assertContains('filter1', $filters1); + // Without args: + $this->assertCount(1, $filters1); + $this->assertSame('filter1', $filters1[0]); - // With args: associative array - $this->assertArrayHasKey('filter2', $filters2); - $this->assertIsArray($filters2['filter2']); + // With args: + $this->assertCount(1, $filters2); + $this->assertSame('filter2:arg1', $filters2[0]); } public function testFilterWithNumericArguments(): void @@ -150,8 +148,8 @@ public function testFilterWithNumericArguments(): void $filters = $filter->getFilters(); - $this->assertArrayHasKey('rate_limit', $filters); - $this->assertSame([100, 60], $filters['rate_limit']); + $this->assertCount(1, $filters); + $this->assertSame('rate_limit:100,60', $filters[0]); } public function testFilterWithMixedTypeArguments(): void @@ -160,8 +158,8 @@ public function testFilterWithMixedTypeArguments(): void $filters = $filter->getFilters(); - $this->assertArrayHasKey('custom', $filters); - $this->assertSame(['string', 123, true], $filters['custom']); + $this->assertCount(1, $filters); + $this->assertSame('custom:string,123,1', $filters[0]); } public function testFilterWithAssociativeArrayArguments(): void @@ -170,8 +168,8 @@ public function testFilterWithAssociativeArrayArguments(): void $filters = $filter->getFilters(); - $this->assertArrayHasKey('configured', $filters); - $this->assertSame(['option1' => 'value1', 'option2' => 'value2'], $filters['configured']); + $this->assertCount(1, $filters); + $this->assertSame('configured:value1,value2', $filters[0]); } public function testBeforeDoesNotModifyRequest(): void diff --git a/user_guide_src/source/incoming/controller_attributes.rst b/user_guide_src/source/incoming/controller_attributes.rst index 10e93c37cddf..842337dcf1b7 100644 --- a/user_guide_src/source/incoming/controller_attributes.rst +++ b/user_guide_src/source/incoming/controller_attributes.rst @@ -44,11 +44,15 @@ The ``Filters`` attribute allows you to specify one or more filters to be applie When filters are applied both by an attribute and in the filter configuration file, they will both be applied, but that could lead to unexpected results. +.. note:: + + Please remember that every parameter applied to the filter will be converted to a string. This behavior affects only filters. + Restrict -------- The ``Restrict`` attribute allows you to restrict access to the class or method based on the domain, the sub-domain, or -the environment the application is running in. Here's an exmaple of how to use the ``Restrict`` attribute: +the environment the application is running in. Here's an example of how to use the ``Restrict`` attribute: .. literalinclude:: controller_attributes/004.php From 75962790d6d2d9f0b06634bb989225efbb807110 Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Thu, 30 Oct 2025 23:22:39 +0300 Subject: [PATCH 39/84] fix: Fixed test Transformers (#9778) --- tests/system/Commands/TransformerGeneratorTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/system/Commands/TransformerGeneratorTest.php b/tests/system/Commands/TransformerGeneratorTest.php index a37a29099eaa..588bf3243d68 100644 --- a/tests/system/Commands/TransformerGeneratorTest.php +++ b/tests/system/Commands/TransformerGeneratorTest.php @@ -27,8 +27,10 @@ final class TransformerGeneratorTest extends CIUnitTestCase protected function tearDown(): void { - $result = str_replace(["\033[0;32m", "\033[0m", "\n"], '', $this->getStreamFilterBuffer()); - $file = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, trim(substr($result, 14))); + $result = str_replace(["\033[0;33m", "\033[0;32m", "\033[0m", "\n"], '', $this->getStreamFilterBuffer()); + preg_match('/APPPATH(\/[^\s"]+\.php)/', $result, $matches); + $file = isset($matches[0]) ? str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $matches[0]) : ''; + if (is_file($file)) { unlink($file); } From 858d0400860fd9efcb09716b4e19c250a0b84f39 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 31 Oct 2025 19:46:35 +0100 Subject: [PATCH 40/84] feat: update robots definition for `UserAgent` class (#9782) * feat: add more user agents to the robots array * reorder robots array --- app/Config/UserAgents.php | 10 ++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 2 ++ 2 files changed, 12 insertions(+) diff --git a/app/Config/UserAgents.php b/app/Config/UserAgents.php index fda73748dbe8..8ddfa05e3515 100644 --- a/app/Config/UserAgents.php +++ b/app/Config/UserAgents.php @@ -230,9 +230,13 @@ class UserAgents extends BaseConfig */ public array $robots = [ 'googlebot' => 'Googlebot', + 'google-pagerenderer' => 'Google Page Renderer', + 'google-read-aloud' => 'Google Read Aloud', + 'google-safety' => 'Google Safety Bot', 'msnbot' => 'MSNBot', 'baiduspider' => 'Baiduspider', 'bingbot' => 'Bing', + 'bingpreview' => 'BingPreview', 'slurp' => 'Inktomi Slurp', 'yahoo' => 'Yahoo', 'ask jeeves' => 'Ask Jeeves', @@ -248,5 +252,11 @@ class UserAgents extends BaseConfig 'ia_archiver' => 'Alexa Crawler', 'MJ12bot' => 'Majestic-12', 'Uptimebot' => 'Uptimebot', + 'duckduckbot' => 'DuckDuckBot', + 'sogou' => 'Sogou Spider', + 'exabot' => 'Exabot', + 'bot' => 'Generic Bot', + 'crawler' => 'Generic Crawler', + 'spider' => 'Generic Spider', ]; } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 565505b9b18e..809d56b41291 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -110,6 +110,8 @@ Migrations Others ------ +- **UserAgents:** Expanded the list of recognized ``robots`` with new search engine and service crawlers. + Model ===== From 2609108c9f9605fa65cf5713a0821132ea310b7c Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Fri, 21 Nov 2025 13:09:33 +0700 Subject: [PATCH 41/84] feat: added `async` & `persistent` options to Cache Redis (#9792) * feat: added async & persistent options to cache predis * docs: update sample config * refactor: phpdocs on cache * feat: redis can used persistent * docs: added support persistent redis option * run cs-fixer * docs: notes async option only used by Predis * Update app/Config/Cache.php Co-authored-by: Michal Sniatala * Update user_guide_src/source/changelogs/v4.7.0.rst Co-authored-by: Michal Sniatala * Update user_guide_src/source/libraries/caching/014.php Co-authored-by: Michal Sniatala * Update app/Config/Cache.php Co-authored-by: John Paul E. Balandan, CPA --------- Co-authored-by: Michal Sniatala Co-authored-by: John Paul E. Balandan, CPA --- app/Config/Cache.php | 22 ++++++++++++++----- system/Cache/Handlers/PredisHandler.php | 14 +++++++----- system/Cache/Handlers/RedisHandler.php | 17 ++++++++------ user_guide_src/source/changelogs/v4.7.0.rst | 2 ++ .../source/libraries/caching/014.php | 12 +++++----- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/app/Config/Cache.php b/app/Config/Cache.php index 1169c95ff7ee..f2077c9d29b0 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -112,14 +112,24 @@ class Cache extends BaseConfig * Your Redis server can be specified below, if you are using * the Redis or Predis drivers. * - * @var array{host?: string, password?: string|null, port?: int, timeout?: int, database?: int} + * @var array{ + * host?: string, + * password?: string|null, + * port?: int, + * timeout?: int, + * async?: bool, + * persistent?: bool, + * database?: int + * } */ public array $redis = [ - 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, - 'timeout' => 0, - 'database' => 0, + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + 'async' => false, // specific to Predis and ignored by the native Redis extension + 'persistent' => false, + 'database' => 0, ]; /** diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 250ea74a88e0..f7fd936abf59 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -36,15 +36,19 @@ class PredisHandler extends BaseHandler * host: string, * password: string|null, * port: int, + * async: bool, + * persistent: bool, * timeout: int * } */ protected $config = [ - 'scheme' => 'tcp', - 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, - 'timeout' => 0, + 'scheme' => 'tcp', + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'async' => false, + 'persistent' => false, + 'timeout' => 0, ]; /** diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 3a13ee07f8e4..a79c757fe331 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -34,15 +34,17 @@ class RedisHandler extends BaseHandler * password: string|null, * port: int, * timeout: int, + * persistent: bool, * database: int, * } */ protected $config = [ - 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, - 'timeout' => 0, - 'database' => 0, + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + 'persistent' => false, + 'database' => 0, ]; /** @@ -82,10 +84,11 @@ public function initialize() $this->redis = new Redis(); try { + $funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect'; + // Note:: If Redis is your primary cache choice, and it is "offline", every page load will end up been delayed by the timeout duration. // I feel like some sort of temporary flag should be set, to indicate that we think Redis is "offline", allowing us to bypass the timeout for a set period of time. - - if (! $this->redis->connect($config['host'], ($config['host'][0] === '/' ? 0 : $config['port']), $config['timeout'])) { + if (! $this->redis->{$funcConnection}($config['host'], ($config['host'][0] === '/' ? 0 : $config['port']), $config['timeout'])) { // Note:: I'm unsure if log_message() is necessary, however I'm not 100% comfortable removing it. log_message('error', 'Cache: Redis connection failed. Check your configuration.'); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 809d56b41291..466c67164a44 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -75,6 +75,8 @@ Libraries - **API Transformers:** This new feature provides a structured way to transform data for API responses. See :ref:`API Transformers ` for details. - **CLI:** Added ``SignalTrait`` to provide unified handling of operating system signals in CLI commands. +- **Cache:** Added ``async`` and ``persistent`` config item to Predis handler. +- **Cache:** Added ``persistent`` config item to Redis handler. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. diff --git a/user_guide_src/source/libraries/caching/014.php b/user_guide_src/source/libraries/caching/014.php index 6b0012696bf3..c7be7cf0f076 100644 --- a/user_guide_src/source/libraries/caching/014.php +++ b/user_guide_src/source/libraries/caching/014.php @@ -9,11 +9,13 @@ class Cache extends BaseConfig // ... public $redis = [ - 'host' => '127.0.0.1', - 'password' => null, - 'port' => 6379, - 'timeout' => 0, - 'database' => 0, + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'async' => false, // specific to Predis and ignored by the native Redis extension + 'persistent' => false, + 'timeout' => 0, + 'database' => 0, ]; // ... From 43bd5bb4295900c7667fddae0f777f9d52416c18 Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Thu, 27 Nov 2025 12:52:36 +0100 Subject: [PATCH 42/84] feat(cache): add `deleteMatching` method definition in CacheInterface (#9809) * feat(cache): add deleteMatching method definition in CacheInterface refs #7828 * docs(changelogs): add note about Cache interface change --- system/Cache/CacheInterface.php | 9 +++++++++ system/Cache/Handlers/BaseHandler.php | 16 ---------------- system/Cache/Handlers/DummyHandler.php | 4 +--- system/Cache/Handlers/FileHandler.php | 4 +--- system/Cache/Handlers/MemcachedHandler.php | 4 +--- system/Cache/Handlers/PredisHandler.php | 4 +--- system/Cache/Handlers/RedisHandler.php | 4 +--- system/Cache/Handlers/WincacheHandler.php | 4 +--- system/Test/Mock/MockCache.php | 4 +--- user_guide_src/source/changelogs/v4.7.0.rst | 1 + 10 files changed, 17 insertions(+), 37 deletions(-) diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php index 0f94cd56f930..0902dcf6f80d 100644 --- a/system/Cache/CacheInterface.php +++ b/system/Cache/CacheInterface.php @@ -51,6 +51,15 @@ public function save(string $key, $value, int $ttl = 60); */ public function delete(string $key); + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return int Number of deleted items + */ + public function deleteMatching(string $pattern): int; + /** * Performs atomic incrementation of a raw stored value. * diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index db482952022a..fa93ffe378f0 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -15,10 +15,8 @@ use Closure; use CodeIgniter\Cache\CacheInterface; -use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Cache; -use Exception; /** * Base class for cache handling @@ -98,18 +96,4 @@ public function remember(string $key, int $ttl, Closure $callback) return $value; } - - /** - * Deletes items from the cache store matching a given pattern. - * - * @param string $pattern Cache items glob-style pattern - * - * @return int - * - * @throws Exception - */ - public function deleteMatching(string $pattern) - { - throw new BadMethodCallException('The deleteMatching method is not implemented.'); - } } diff --git a/system/Cache/Handlers/DummyHandler.php b/system/Cache/Handlers/DummyHandler.php index 10300c79694b..4328f62cfe05 100644 --- a/system/Cache/Handlers/DummyHandler.php +++ b/system/Cache/Handlers/DummyHandler.php @@ -63,10 +63,8 @@ public function delete(string $key) /** * {@inheritDoc} - * - * @return int */ - public function deleteMatching(string $pattern) + public function deleteMatching(string $pattern): int { return 0; } diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index 010fc86d9cdd..5b3938e1db0b 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -131,10 +131,8 @@ public function delete(string $key) /** * {@inheritDoc} - * - * @return int */ - public function deleteMatching(string $pattern) + public function deleteMatching(string $pattern): int { $deleted = 0; diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 91ac38a9427d..b19395dcb244 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -182,10 +182,8 @@ public function delete(string $key) /** * {@inheritDoc} - * - * @return never */ - public function deleteMatching(string $pattern) + public function deleteMatching(string $pattern): never { throw new BadMethodCallException('The deleteMatching method is not implemented for Memcached. You must select File, Redis or Predis handlers to use it.'); } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index f7fd936abf59..46e697f50f0c 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -155,10 +155,8 @@ public function delete(string $key) /** * {@inheritDoc} - * - * @return int */ - public function deleteMatching(string $pattern) + public function deleteMatching(string $pattern): int { $matchedKeys = []; diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index a79c757fe331..c3455693b8e2 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -179,10 +179,8 @@ public function delete(string $key) /** * {@inheritDoc} - * - * @return int */ - public function deleteMatching(string $pattern) + public function deleteMatching(string $pattern): int { /** @var list $matchedKeys */ $matchedKeys = []; diff --git a/system/Cache/Handlers/WincacheHandler.php b/system/Cache/Handlers/WincacheHandler.php index d4d97701b135..2207296457d7 100644 --- a/system/Cache/Handlers/WincacheHandler.php +++ b/system/Cache/Handlers/WincacheHandler.php @@ -75,10 +75,8 @@ public function delete(string $key) /** * {@inheritDoc} - * - * @return never */ - public function deleteMatching(string $pattern) + public function deleteMatching(string $pattern): never { throw new BadMethodCallException('The deleteMatching method is not implemented for Wincache. You must select File, Redis or Predis handlers to use it.'); } diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 6699ad266c63..07b44895cd09 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -129,10 +129,8 @@ public function delete(string $key) /** * Deletes items from the cache store matching a given pattern. - * - * @return int */ - public function deleteMatching(string $pattern) + public function deleteMatching(string $pattern): int { $count = 0; diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 466c67164a44..f9dcb6c82d3f 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -43,6 +43,7 @@ across all insert/update operations. Interface Changes ================= +- **Cache:** The ``CacheInterface`` now includes the ``deleteMatching()`` method. If you've implemented your own caching driver from scratch, you will need to provide an implementation for this method to ensure compatibility. - **Images:** The ``ImageHandlerInterface`` now includes a new method: ``clearMetadata()``. If you've implemented your own handler from scratch, you will need to provide an implementation for this method to ensure compatibility. Method Signature Changes From 76bea167c58dbfabd1a01a9346d777b4226b6f84 Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Sat, 29 Nov 2025 10:33:50 +0100 Subject: [PATCH 43/84] feat(cache): add native types to all CacheInterface methods (#9811) * feat(cache): add native return types to all CacheInterface methods + remove deprecated `false` type in `getMetaData()` method + remove unnecessary @inheritDoc annotation * docs(changelogs): add note for WincacheHandler bug fix + clarify deprecation removal of `false` type for `CacheInterface::getMetaData()` * feat(cache): set native type for $value param in CacheInterface::save() method * docs(changelogs): update method signature changes for CacheInterface --- system/Cache/CacheInterface.php | 31 ++++----- system/Cache/Handlers/BaseHandler.php | 4 +- system/Cache/Handlers/DummyHandler.php | 56 +++-------------- system/Cache/Handlers/FileHandler.php | 63 +++++-------------- system/Cache/Handlers/MemcachedHandler.php | 53 +++------------- system/Cache/Handlers/PredisHandler.php | 51 +++------------ system/Cache/Handlers/RedisHandler.php | 51 +++------------ system/Cache/Handlers/WincacheHandler.php | 61 +++++------------- system/Test/Mock/MockCache.php | 32 +++------- .../system/Cache/Handlers/FileHandlerTest.php | 2 +- .../Cache/Handlers/MemcachedHandlerTest.php | 2 +- .../Cache/Handlers/RedisHandlerTest.php | 1 - user_guide_src/source/changelogs/v4.7.0.rst | 11 ++++ 13 files changed, 103 insertions(+), 315 deletions(-) diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php index 0902dcf6f80d..9ae83f29283d 100644 --- a/system/Cache/CacheInterface.php +++ b/system/Cache/CacheInterface.php @@ -17,19 +17,15 @@ interface CacheInterface { /** * Takes care of any handler-specific setup that must be done. - * - * @return void */ - public function initialize(); + public function initialize(): void; /** * Attempts to fetch an item from the cache store. * * @param string $key Cache item name - * - * @return mixed */ - public function get(string $key); + public function get(string $key): mixed; /** * Saves an item to the cache store. @@ -40,7 +36,7 @@ public function get(string $key); * * @return bool Success or failure */ - public function save(string $key, $value, int $ttl = 60); + public function save(string $key, mixed $value, int $ttl = 60): bool; /** * Deletes a specific item from the cache store. @@ -49,7 +45,7 @@ public function save(string $key, $value, int $ttl = 60); * * @return bool Success or failure */ - public function delete(string $key); + public function delete(string $key): bool; /** * Deletes items from the cache store matching a given pattern. @@ -65,27 +61,23 @@ public function deleteMatching(string $pattern): int; * * @param string $key Cache ID * @param int $offset Step/value to increase by - * - * @return bool|int */ - public function increment(string $key, int $offset = 1); + public function increment(string $key, int $offset = 1): bool|int; /** * Performs atomic decrementation of a raw stored value. * * @param string $key Cache ID * @param int $offset Step/value to increase by - * - * @return bool|int */ - public function decrement(string $key, int $offset = 1); + public function decrement(string $key, int $offset = 1): bool|int; /** * Will delete all items in the entire cache. * * @return bool Success or failure */ - public function clean(); + public function clean(): bool; /** * Returns information on the entire cache. @@ -95,18 +87,17 @@ public function clean(); * * @return array|false|object|null */ - public function getCacheInfo(); + public function getCacheInfo(): array|false|object|null; /** * Returns detailed information about the specific item in the cache. * * @param string $key Cache item name. * - * @return array|false|null Returns null if the item does not exist, otherwise array - * with at least the 'expire' key for absolute epoch expiry (or null). - * Some handlers may return false when an item does not exist, which is deprecated. + * @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); + public function getMetaData(string $key): ?array; /** * Determines if the driver is supported on this system. diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index fa93ffe378f0..2d9aeff489d1 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -81,10 +81,8 @@ public static function validateKey($key, $prefix = ''): string * @param string $key Cache item name * @param int $ttl Time to live * @param Closure(): mixed $callback Callback return value - * - * @return mixed */ - public function remember(string $key, int $ttl, Closure $callback) + public function remember(string $key, int $ttl, Closure $callback): mixed { $value = $this->get($key); diff --git a/system/Cache/Handlers/DummyHandler.php b/system/Cache/Handlers/DummyHandler.php index 4328f62cfe05..a30475965d6f 100644 --- a/system/Cache/Handlers/DummyHandler.php +++ b/system/Cache/Handlers/DummyHandler.php @@ -22,96 +22,60 @@ */ class DummyHandler extends BaseHandler { - /** - * {@inheritDoc} - */ - public function initialize() + public function initialize(): void { } - /** - * {@inheritDoc} - */ - public function get(string $key) + public function get(string $key): mixed { return null; } - /** - * {@inheritDoc} - */ - public function remember(string $key, int $ttl, Closure $callback) + public function remember(string $key, int $ttl, Closure $callback): mixed { return null; } - /** - * {@inheritDoc} - */ - public function save(string $key, $value, int $ttl = 60) + public function save(string $key, mixed $value, int $ttl = 60): bool { return true; } - /** - * {@inheritDoc} - */ - public function delete(string $key) + public function delete(string $key): bool { return true; } - /** - * {@inheritDoc} - */ public function deleteMatching(string $pattern): int { return 0; } - /** - * {@inheritDoc} - */ - public function increment(string $key, int $offset = 1) + public function increment(string $key, int $offset = 1): bool { return true; } - /** - * {@inheritDoc} - */ - public function decrement(string $key, int $offset = 1) + public function decrement(string $key, int $offset = 1): bool { return true; } - /** - * {@inheritDoc} - */ - public function clean() + public function clean(): bool { return true; } - /** - * {@inheritDoc} - */ - public function getCacheInfo() + public function getCacheInfo(): ?array { return null; } - /** - * {@inheritDoc} - */ - public function getMetaData(string $key) + public function getMetaData(string $key): ?array { return null; } - /** - * {@inheritDoc} - */ public function isSupported(): bool { return true; diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index 5b3938e1db0b..adc31c8f447c 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -72,17 +72,11 @@ public function __construct(Cache $config) helper('filesystem'); } - /** - * {@inheritDoc} - */ - public function initialize() + public function initialize(): void { } - /** - * {@inheritDoc} - */ - public function get(string $key) + public function get(string $key): mixed { $key = static::validateKey($key, $this->prefix); $data = $this->getItem($key); @@ -90,10 +84,7 @@ public function get(string $key) return is_array($data) ? $data['data'] : null; } - /** - * {@inheritDoc} - */ - public function save(string $key, $value, int $ttl = 60) + public function save(string $key, mixed $value, int $ttl = 60): bool { $key = static::validateKey($key, $this->prefix); @@ -119,19 +110,13 @@ public function save(string $key, $value, int $ttl = 60) return false; } - /** - * {@inheritDoc} - */ - public function delete(string $key) + public function delete(string $key): bool { $key = static::validateKey($key, $this->prefix); return is_file($this->path . $key) && unlink($this->path . $key); } - /** - * {@inheritDoc} - */ public function deleteMatching(string $pattern): int { $deleted = 0; @@ -145,10 +130,7 @@ public function deleteMatching(string $pattern): int return $deleted; } - /** - * {@inheritDoc} - */ - public function increment(string $key, int $offset = 1) + public function increment(string $key, int $offset = 1): bool|int { $prefixedKey = static::validateKey($key, $this->prefix); $tmp = $this->getItem($prefixedKey); @@ -168,39 +150,27 @@ public function increment(string $key, int $offset = 1) return $this->save($key, $value, $ttl) ? $value : false; } - /** - * {@inheritDoc} - */ - public function decrement(string $key, int $offset = 1) + public function decrement(string $key, int $offset = 1): bool|int { return $this->increment($key, -$offset); } - /** - * {@inheritDoc} - */ - public function clean() + public function clean(): bool { return delete_files($this->path, false, true); } - /** - * {@inheritDoc} - */ - public function getCacheInfo() + public function getCacheInfo(): array { return get_dir_file_info($this->path); } - /** - * {@inheritDoc} - */ - public function getMetaData(string $key) + public function getMetaData(string $key): ?array { $key = static::validateKey($key, $this->prefix); if (false === $data = $this->getItem($key)) { - return false; // @TODO This will return null in a future release + return null; } return [ @@ -210,9 +180,6 @@ public function getMetaData(string $key) ]; } - /** - * {@inheritDoc} - */ public function isSupported(): bool { return is_writable($this->path); @@ -224,7 +191,7 @@ public function isSupported(): bool * * @return array{data: mixed, ttl: int, time: int}|false */ - protected function getItem(string $filename) + protected function getItem(string $filename): array|false { if (! is_file($this->path . $filename)) { return false; @@ -271,10 +238,8 @@ protected function getItem(string $filename) * @param string $path * @param string $data * @param string $mode - * - * @return bool */ - protected function writeFile($path, $data, $mode = 'wb') + protected function writeFile($path, $data, $mode = 'wb'): bool { if (($fp = @fopen($path, $mode)) === false) { return false; @@ -353,7 +318,7 @@ protected function deleteFiles(string $path, bool $delDir = false, bool $htdocs * relative_path: string, * }>|false */ - protected function getDirFileInfo(string $sourceDir, bool $topLevelOnly = true, bool $_recursion = false) + protected function getDirFileInfo(string $sourceDir, bool $topLevelOnly = true, bool $_recursion = false): array|false { static $filedata = []; @@ -412,7 +377,7 @@ protected function getDirFileInfo(string $sourceDir, bool $topLevelOnly = true, * fileperms?: int * }|false */ - protected function getFileInfo(string $file, $returnedValues = ['name', 'server_path', 'size', 'date']) + protected function getFileInfo(string $file, $returnedValues = ['name', 'server_path', 'size', 'date']): array|false { if (! is_file($file)) { return false; diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index b19395dcb244..14bc8e80a144 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -69,10 +69,7 @@ public function __destruct() } } - /** - * {@inheritDoc} - */ - public function initialize() + public function initialize(): void { try { if (class_exists(Memcached::class)) { @@ -116,10 +113,7 @@ public function initialize() } } - /** - * {@inheritDoc} - */ - public function get(string $key) + public function get(string $key): mixed { $data = []; $key = static::validateKey($key, $this->prefix); @@ -144,10 +138,7 @@ public function get(string $key) return is_array($data) ? $data[0] : $data; } - /** - * {@inheritDoc} - */ - public function save(string $key, $value, int $ttl = 60) + public function save(string $key, mixed $value, int $ttl = 60): bool { $key = static::validateKey($key, $this->prefix); @@ -170,28 +161,19 @@ public function save(string $key, $value, int $ttl = 60) return false; } - /** - * {@inheritDoc} - */ - public function delete(string $key) + public function delete(string $key): bool { $key = static::validateKey($key, $this->prefix); return $this->memcached->delete($key); } - /** - * {@inheritDoc} - */ public function deleteMatching(string $pattern): never { throw new BadMethodCallException('The deleteMatching method is not implemented for Memcached. You must select File, Redis or Predis handlers to use it.'); } - /** - * {@inheritDoc} - */ - public function increment(string $key, int $offset = 1) + public function increment(string $key, int $offset = 1): false|int { if (! $this->config['raw']) { return false; @@ -202,10 +184,7 @@ public function increment(string $key, int $offset = 1) return $this->memcached->increment($key, $offset, $offset, 60); } - /** - * {@inheritDoc} - */ - public function decrement(string $key, int $offset = 1) + public function decrement(string $key, int $offset = 1): false|int { if (! $this->config['raw']) { return false; @@ -218,33 +197,24 @@ public function decrement(string $key, int $offset = 1) return $this->memcached->decrement($key, $offset, $offset, 60); } - /** - * {@inheritDoc} - */ - public function clean() + public function clean(): bool { return $this->memcached->flush(); } - /** - * {@inheritDoc} - */ - public function getCacheInfo() + public function getCacheInfo(): array|false { return $this->memcached->getStats(); } - /** - * {@inheritDoc} - */ - public function getMetaData(string $key) + public function getMetaData(string $key): ?array { $key = static::validateKey($key, $this->prefix); $stored = $this->memcached->get($key); // if not an array, don't try to count for PHP7.2 if (! is_array($stored) || count($stored) !== 3) { - return false; // @TODO This will return null in a future release + return null; } [$data, $time, $limit] = $stored; @@ -256,9 +226,6 @@ public function getMetaData(string $key) ]; } - /** - * {@inheritDoc} - */ public function isSupported(): bool { return extension_loaded('memcached') || extension_loaded('memcache'); diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 46e697f50f0c..c7c7a6fdd760 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -70,10 +70,7 @@ public function __construct(Cache $config) } } - /** - * {@inheritDoc} - */ - public function initialize() + public function initialize(): void { try { $this->redis = new Client($this->config, ['prefix' => $this->prefix]); @@ -83,10 +80,7 @@ public function initialize() } } - /** - * {@inheritDoc} - */ - public function get(string $key) + public function get(string $key): mixed { $key = static::validateKey($key); @@ -107,10 +101,7 @@ public function get(string $key) }; } - /** - * {@inheritDoc} - */ - public function save(string $key, $value, int $ttl = 60) + public function save(string $key, mixed $value, int $ttl = 60): bool { $key = static::validateKey($key); @@ -143,19 +134,13 @@ public function save(string $key, $value, int $ttl = 60) return true; } - /** - * {@inheritDoc} - */ - public function delete(string $key) + public function delete(string $key): bool { $key = static::validateKey($key); return $this->redis->del($key) === 1; } - /** - * {@inheritDoc} - */ public function deleteMatching(string $pattern): int { $matchedKeys = []; @@ -167,46 +152,31 @@ public function deleteMatching(string $pattern): int return $this->redis->del($matchedKeys); } - /** - * {@inheritDoc} - */ - public function increment(string $key, int $offset = 1) + public function increment(string $key, int $offset = 1): int { $key = static::validateKey($key); return $this->redis->hincrby($key, 'data', $offset); } - /** - * {@inheritDoc} - */ - public function decrement(string $key, int $offset = 1) + public function decrement(string $key, int $offset = 1): int { $key = static::validateKey($key); return $this->redis->hincrby($key, 'data', -$offset); } - /** - * {@inheritDoc} - */ - public function clean() + public function clean(): bool { return $this->redis->flushdb()->getPayload() === 'OK'; } - /** - * {@inheritDoc} - */ - public function getCacheInfo() + public function getCacheInfo(): array { return $this->redis->info(); } - /** - * {@inheritDoc} - */ - public function getMetaData(string $key) + public function getMetaData(string $key): ?array { $key = static::validateKey($key); @@ -226,9 +196,6 @@ public function getMetaData(string $key) return null; } - /** - * {@inheritDoc} - */ public function isSupported(): bool { return class_exists(Client::class); diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index c3455693b8e2..52b16b8e64ef 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -74,10 +74,7 @@ public function __destruct() } } - /** - * {@inheritDoc} - */ - public function initialize() + public function initialize(): void { $config = $this->config; @@ -111,10 +108,7 @@ public function initialize() } } - /** - * {@inheritDoc} - */ - public function get(string $key) + public function get(string $key): mixed { $key = static::validateKey($key, $this->prefix); $data = $this->redis->hMget($key, ['__ci_type', '__ci_value']); @@ -131,10 +125,7 @@ public function get(string $key) }; } - /** - * {@inheritDoc} - */ - public function save(string $key, $value, int $ttl = 60) + public function save(string $key, mixed $value, int $ttl = 60): bool { $key = static::validateKey($key, $this->prefix); @@ -167,19 +158,13 @@ public function save(string $key, $value, int $ttl = 60) return true; } - /** - * {@inheritDoc} - */ - public function delete(string $key) + public function delete(string $key): bool { $key = static::validateKey($key, $this->prefix); return $this->redis->del($key) === 1; } - /** - * {@inheritDoc} - */ public function deleteMatching(string $pattern): int { /** @var list $matchedKeys */ @@ -199,44 +184,29 @@ public function deleteMatching(string $pattern): int return $this->redis->del($matchedKeys); } - /** - * {@inheritDoc} - */ - public function increment(string $key, int $offset = 1) + public function increment(string $key, int $offset = 1): int { $key = static::validateKey($key, $this->prefix); return $this->redis->hIncrBy($key, '__ci_value', $offset); } - /** - * {@inheritDoc} - */ - public function decrement(string $key, int $offset = 1) + public function decrement(string $key, int $offset = 1): int { return $this->increment($key, -$offset); } - /** - * {@inheritDoc} - */ - public function clean() + public function clean(): bool { return $this->redis->flushDB(); } - /** - * {@inheritDoc} - */ - public function getCacheInfo() + public function getCacheInfo(): array { return $this->redis->info(); } - /** - * {@inheritDoc} - */ - public function getMetaData(string $key) + public function getMetaData(string $key): ?array { $value = $this->get($key); @@ -255,9 +225,6 @@ public function getMetaData(string $key) return null; } - /** - * {@inheritDoc} - */ public function isSupported(): bool { return extension_loaded('redis'); diff --git a/system/Cache/Handlers/WincacheHandler.php b/system/Cache/Handlers/WincacheHandler.php index 2207296457d7..5d520a05185e 100644 --- a/system/Cache/Handlers/WincacheHandler.php +++ b/system/Cache/Handlers/WincacheHandler.php @@ -32,17 +32,11 @@ public function __construct(Cache $config) $this->prefix = $config->prefix; } - /** - * {@inheritDoc} - */ - public function initialize() + public function initialize(): void { } - /** - * {@inheritDoc} - */ - public function get(string $key) + public function get(string $key): mixed { $key = static::validateKey($key, $this->prefix); $success = false; @@ -53,74 +47,54 @@ public function get(string $key) return $success ? $data : null; } - /** - * {@inheritDoc} - */ - public function save(string $key, $value, int $ttl = 60) + public function save(string $key, mixed $value, int $ttl = 60): bool { $key = static::validateKey($key, $this->prefix); return wincache_ucache_set($key, $value, $ttl); } - /** - * {@inheritDoc} - */ - public function delete(string $key) + public function delete(string $key): bool { $key = static::validateKey($key, $this->prefix); return wincache_ucache_delete($key); } - /** - * {@inheritDoc} - */ public function deleteMatching(string $pattern): never { throw new BadMethodCallException('The deleteMatching method is not implemented for Wincache. You must select File, Redis or Predis handlers to use it.'); } - /** - * {@inheritDoc} - */ - public function increment(string $key, int $offset = 1) + public function increment(string $key, int $offset = 1): bool { $key = static::validateKey($key, $this->prefix); - return wincache_ucache_inc($key, $offset); + $result = wincache_ucache_inc($key, $offset); + + return $result !== false; } - /** - * {@inheritDoc} - */ - public function decrement(string $key, int $offset = 1) + public function decrement(string $key, int $offset = 1): bool { $key = static::validateKey($key, $this->prefix); - return wincache_ucache_dec($key, $offset); + $result = wincache_ucache_dec($key, $offset); + + return $result !== false; } - /** - * {@inheritDoc} - */ - public function clean() + public function clean(): bool { return wincache_ucache_clear(); } - /** - * {@inheritDoc} - */ - public function getCacheInfo() + public function getCacheInfo(): array|false { return wincache_ucache_info(true); } - /** - * {@inheritDoc} - */ - public function getMetaData(string $key) + public function getMetaData(string $key): ?array { $key = static::validateKey($key, $this->prefix); @@ -137,12 +111,9 @@ public function getMetaData(string $key) ]; } - return false; // @TODO This will return null in a future release + return null; } - /** - * {@inheritDoc} - */ public function isSupported(): bool { return extension_loaded('wincache') && ini_get('wincache.ucenabled'); diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 07b44895cd09..27af8a1ad213 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -44,10 +44,8 @@ class MockCache extends BaseHandler implements CacheInterface /** * Takes care of any handler-specific setup that must be done. - * - * @return void */ - public function initialize() + public function initialize(): void { } @@ -58,7 +56,7 @@ public function initialize() * * @return bool|null */ - public function get(string $key) + public function get(string $key): mixed { $key = static::validateKey($key, $this->prefix); @@ -70,7 +68,7 @@ public function get(string $key) * * @return bool|null */ - public function remember(string $key, int $ttl, Closure $callback) + public function remember(string $key, int $ttl, Closure $callback): mixed { $value = $this->get($key); @@ -92,10 +90,8 @@ public function remember(string $key, int $ttl, Closure $callback) * @param string $key Cache item name * @param mixed $value the data to save * @param int $ttl Time To Live, in seconds (default 60) - * - * @return bool */ - public function save(string $key, $value, int $ttl = 60) + public function save(string $key, $value, int $ttl = 60): bool { if ($this->bypass) { return false; @@ -111,10 +107,8 @@ public function save(string $key, $value, int $ttl = 60) /** * Deletes a specific item from the cache store. - * - * @return bool */ - public function delete(string $key) + public function delete(string $key): bool { $key = static::validateKey($key, $this->prefix); @@ -146,10 +140,8 @@ public function deleteMatching(string $pattern): int /** * Performs atomic incrementation of a raw stored value. - * - * @return bool */ - public function increment(string $key, int $offset = 1) + public function increment(string $key, int $offset = 1): bool { $key = static::validateKey($key, $this->prefix); $data = $this->cache[$key] ?: null; @@ -165,10 +157,8 @@ public function increment(string $key, int $offset = 1) /** * Performs atomic decrementation of a raw stored value. - * - * @return bool */ - public function decrement(string $key, int $offset = 1) + public function decrement(string $key, int $offset = 1): bool { $key = static::validateKey($key, $this->prefix); @@ -185,10 +175,8 @@ public function decrement(string $key, int $offset = 1) /** * Will delete all items in the entire cache. - * - * @return bool */ - public function clean() + public function clean(): true { $this->cache = []; $this->expirations = []; @@ -204,7 +192,7 @@ public function clean() * * @return list Keys currently present in the store */ - public function getCacheInfo() + public function getCacheInfo(): array { return array_keys($this->cache); } @@ -216,7 +204,7 @@ public function getCacheInfo() * otherwise, array with the 'expire' key for * absolute epoch expiry (or null). */ - public function getMetaData(string $key) + public function getMetaData(string $key): ?array { // Misses return null if (! array_key_exists($key, $this->expirations)) { diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index 0819a5a9df06..6a709d60caf8 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -362,7 +362,7 @@ public function testFileHandler(): void public function testGetMetaDataMiss(): void { - $this->assertFalse($this->handler->getMetaData(self::$dummy)); + $this->assertNull($this->handler->getMetaData(self::$dummy)); } #[RequiresOperatingSystem('Linux|Darwin')] diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index f4114d4742e7..06ae6112a432 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -186,6 +186,6 @@ public function testIsSupported(): void public function testGetMetaDataMiss(): void { - $this->assertFalse($this->handler->getMetaData(self::$dummy)); + $this->assertNull($this->handler->getMetaData(self::$dummy)); } } diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index 467abc675372..6abf9f2eb0fa 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -222,7 +222,6 @@ public function testGetMetadataNotNull(): void $metadata = $this->handler->getMetaData(self::$key1); $this->assertNotNull($metadata); - $this->assertIsArray($metadata); } public function testIsSupported(): void diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index f9dcb6c82d3f..5a3d9a401b2c 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -59,6 +59,15 @@ Method Signature Changes - ``CodeIgniter\HTTP\CURLRequest::setAuth()`` - ``CodeIgniter\HTTP\URI::setUserInfo()`` - ``CodeIgniter\Security\Security::derandomize()`` +- Added native types to ``CacheInterface`` methods that were missing them. The following methods were updated: + - ``initialize()`` + - ``save()`` + - ``delete()`` + - ``increment()`` + - ``decrement()`` + - ``clean()`` + - ``getCacheInfo()`` + - ``getMetaData()`` Removed Deprecated Items ======================== @@ -66,6 +75,7 @@ Removed Deprecated Items - **Text Helper:** The deprecated types in ``random_string()`` function: ``basic``, ``md5``, and ``sha1`` has been removed. - **BaseModel:** The deprecated method ``transformDataRowToArray()`` has been removed. - **CodeIgniter:** The deprecated ``CodeIgniter\CodeIgniter::resolvePlatformExtensions()`` has been removed. +- **Cache:** The deprecated return type ``false`` for ``CodeIgniter\Cache\CacheInterface::getMetaData()`` has been replaced with ``null`` type. ************ Enhancements @@ -154,6 +164,7 @@ Bugs Fixed ********** - **Cookie:** The ``CookieInterface::SAMESITE_STRICT``, ``CookieInterface::SAMESITE_LAX``, and ``CookieInterface::SAMESITE_NONE`` constants are now written in ucfirst style to be consistent with usage in the rest of the framework. +- **Cache:** Changed ``WincacheHandler::increment()`` and ``WincacheHandler::decrement()`` to return ``bool`` instead of ``mixed``. See the repo's `CHANGELOG.md `_ From 04a34ed7981d8928a949f94cd82f30ef426646b9 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 12 Dec 2025 08:16:36 +0100 Subject: [PATCH 44/84] fix: tests with migrations_lock (#9833) --- .../Database/Migrations/20160428212500_Create_test_tables.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php index 6af55fa7c9c9..74fb2aa072f3 100644 --- a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php +++ b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php @@ -189,6 +189,7 @@ public function down(): void $this->forge->dropTable('stringifypkey', true); $this->forge->dropTable('without_auto_increment', true); $this->forge->dropTable('ip_table', true); + $this->forge->dropTable('migrations_lock', true); if (in_array($this->db->DBDriver, ['MySQLi', 'Postgre'], true)) { $this->forge->dropTable('ci_sessions', true); From c9d3dcdddef172ed8e3aeec7c373e6dc3f01a49a Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Sat, 13 Dec 2025 11:26:20 +0300 Subject: [PATCH 45/84] refactor: Types for `BaseModel`, `Model` and dependencies (#9830) * refactor: Update types for `BaseModel`, `Model` and dependencies * refactor: Apply suggestions (revert) * fix: Revert check ID on update --- deptrac.yaml | 1 + system/BaseModel.php | 427 +++++++------- system/Database/BaseBuilder.php | 4 +- system/Model.php | 257 ++------- tests/system/Models/UpdateModelTest.php | 2 +- .../Models/ValidationModelRuleGroupTest.php | 2 +- tests/system/Models/ValidationModelTest.php | 2 +- user_guide_src/source/changelogs/v4.7.0.rst | 2 + user_guide_src/source/models/model.rst | 4 +- utils/phpstan-baseline/argument.type.neon | 11 +- .../codeigniter.superglobalAccessAssign.neon | 4 +- utils/phpstan-baseline/loader.neon | 2 +- .../method.childReturnType.neon | 15 +- utils/phpstan-baseline/method.notFound.neon | 28 +- .../missingType.callable.neon | 8 +- .../missingType.iterableValue.neon | 536 +++++------------- 16 files changed, 431 insertions(+), 874 deletions(-) diff --git a/deptrac.yaml b/deptrac.yaml index c70f4d7126cc..a35887183f05 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -209,6 +209,7 @@ deptrac: - I18n Model: - Database + - DataCaster - DataConverter - Entity - I18n diff --git a/system/BaseModel.php b/system/BaseModel.php index 8e81863adfb9..6b2edc43947f 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -19,7 +19,9 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Database\Query; +use CodeIgniter\DataCaster\Cast\CastInterface; use CodeIgniter\DataConverter\DataConverter; +use CodeIgniter\Entity\Cast\CastInterface as EntityCastInterface; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\ModelException; @@ -35,7 +37,7 @@ /** * The BaseModel class provides a number of convenient features that * makes working with a databases less painful. Extending this class - * provide means of implementing various database systems + * provide means of implementing various database systems. * * It will: * - simplifies pagination @@ -60,21 +62,22 @@ abstract class BaseModel { /** * Pager instance. - * Populated after calling $this->paginate() + * + * Populated after calling `$this->paginate()`. * * @var Pager */ public $pager; /** - * Database Connection + * Database Connection. * * @var BaseConnection */ protected $db; /** - * Last insert ID + * Last insert ID. * * @var int|string */ @@ -90,14 +93,17 @@ abstract class BaseModel /** * The format that the results should be returned as. - * Will be overridden if the as* methods are used. * - * @var string + * Will be overridden if the `$this->asArray()`, `$this->asObject()` methods are used. + * + * @var 'array'|'object'|class-string */ protected $returnType = 'array'; /** - * Used by asArray() and asObject() to provide + * The temporary format of the result. + * + * Used by `$this->asArray()` and `$this->asObject()` to provide * temporary overrides of model default. * * @var 'array'|'object'|class-string @@ -107,14 +113,14 @@ abstract class BaseModel /** * Array of column names and the type of value to cast. * - * @var array [column => type] + * @var array Array order `['column' => 'type']`. */ protected array $casts = []; /** * Custom convert handlers. * - * @var array [type => classname] + * @var array> Array order `['type' => 'classname']`. */ protected array $castHandlers = []; @@ -122,9 +128,9 @@ abstract class BaseModel /** * Determines whether the model should protect field names during - * mass assignment operations such as insert() and update(). + * mass assignment operations such as $this->insert(), $this->update(). * - * When set to true, only the fields explicitly defined in the $allowedFields + * When set to `true`, only the fields explicitly defined in the `$allowedFields` * property will be allowed for mass assignment. This helps prevent * unintended modification of database fields and improves security * by avoiding mass assignment vulnerabilities. @@ -153,21 +159,19 @@ abstract class BaseModel * The type of column that created_at and updated_at * are expected to. * - * Allowed: 'datetime', 'date', 'int' - * - * @var string + * @var 'date'|'datetime'|'int' */ protected $dateFormat = 'datetime'; /** - * The column used for insert timestamps + * The column used for insert timestamps. * * @var string */ protected $createdField = 'created_at'; /** - * The column used for update timestamps + * The column used for update timestamps. * * @var string */ @@ -183,15 +187,15 @@ abstract class BaseModel protected $useSoftDeletes = false; /** - * Used by withDeleted to override the - * model's softDelete setting. + * Used by $this->withDeleted() to override the + * model's "softDelete" setting. * * @var bool */ protected $tempUseSoftDeletes; /** - * The column used to save soft delete state + * The column used to save soft delete state. * * @var string */ @@ -211,7 +215,7 @@ abstract class BaseModel * Rules used to validate data in insert(), update(), save(), * insertBatch(), and updateBatch() methods. * - * The array must match the format of data passed to the Validation + * The array must match the format of data passed to the `Validation` * library. * * @see https://codeigniter4.github.io/userguide/models/model.html#setting-validation-rules @@ -224,12 +228,14 @@ abstract class BaseModel * Contains any custom error messages to be * used during data validation. * - * @var array> + * @var array> The column is used as the keys. */ protected $validationMessages = []; /** - * Skip the model's validation. Used in conjunction with skipValidation() + * Skip the model's validation. + * + * Used in conjunction with `$this->skipValidation()` * to skip data validation for any future calls. * * @var bool @@ -265,99 +271,99 @@ abstract class BaseModel */ /** - * Whether to trigger the defined callbacks + * Whether to trigger the defined callbacks. * * @var bool */ protected $allowCallbacks = true; /** - * Used by allowCallbacks() to override the - * model's allowCallbacks setting. + * Used by $this->allowCallbacks() to override the + * model's $allowCallbacks setting. * * @var bool */ protected $tempAllowCallbacks; /** - * Callbacks for beforeInsert + * Callbacks for "beforeInsert" event. * * @var list */ protected $beforeInsert = []; /** - * Callbacks for afterInsert + * Callbacks for "afterInsert" event. * * @var list */ protected $afterInsert = []; /** - * Callbacks for beforeUpdate + * Callbacks for "beforeUpdate" event. * * @var list */ protected $beforeUpdate = []; /** - * Callbacks for afterUpdate + * Callbacks for "afterUpdate" event. * * @var list */ protected $afterUpdate = []; /** - * Callbacks for beforeInsertBatch + * Callbacks for "beforeInsertBatch" event. * * @var list */ protected $beforeInsertBatch = []; /** - * Callbacks for afterInsertBatch + * Callbacks for "afterInsertBatch" event. * * @var list */ protected $afterInsertBatch = []; /** - * Callbacks for beforeUpdateBatch + * Callbacks for "beforeUpdateBatch" event. * * @var list */ protected $beforeUpdateBatch = []; /** - * Callbacks for afterUpdateBatch + * Callbacks for "afterUpdateBatch" event. * * @var list */ protected $afterUpdateBatch = []; /** - * Callbacks for beforeFind + * Callbacks for "beforeFind" event. * * @var list */ protected $beforeFind = []; /** - * Callbacks for afterFind + * Callbacks for "afterFind" event. * * @var list */ protected $afterFind = []; /** - * Callbacks for beforeDelete + * Callbacks for "beforeDelete" event. * * @var list */ protected $beforeDelete = []; /** - * Callbacks for afterDelete + * Callbacks for "afterDelete" event. * * @var list */ @@ -408,23 +414,22 @@ protected function initialize() } /** - * Fetches the row of database. - * This method works only with dbCalls. + * Fetches the row(s) of database with a primary key + * matching $id. + * This method works only with DB calls. * - * @param bool $singleton Single or multiple results - * @param array|int|string|null $id One primary key or an array of primary keys + * @param bool $singleton Single or multiple results. + * @param int|list|string|null $id One primary key or an array of primary keys. * - * @return array|object|null The resulting row of data, or null. + * @return ($singleton is true ? object|row_array|null : list) The resulting row of data or `null`. */ abstract protected function doFind(bool $singleton, $id = null); /** * Fetches the column of database. - * This method works only with dbCalls. - * - * @param string $columnName Column Name + * This method works only with DB calls. * - * @return array|null The resulting row of data, or null if no data found. + * @return list|null The resulting row of data or `null` if no data found. * * @throws DataException */ @@ -432,29 +437,25 @@ abstract protected function doFindColumn(string $columnName); /** * Fetches all results, while optionally limiting them. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param int|null $limit Limit - * @param int $offset Offset - * - * @return array + * @return list */ abstract protected function doFindAll(?int $limit = null, int $offset = 0); /** * Returns the first row of the result set. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @return array|object|null + * @return object|row_array|null */ abstract protected function doFirst(); /** * Inserts data into the current database. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param array $row Row data - * @phpstan-param row_array $row + * @param row_array $row * * @return bool */ @@ -462,68 +463,68 @@ abstract protected function doInsert(array $row); /** * Compiles batch insert and runs the queries, validating each row prior. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param array|null $set An associative array of insert values - * @param bool|null $escape Whether to escape values - * @param int $batchSize The size of the batch to run - * @param bool $testing True means only number of records is returned, false will execute the query + * @param list|null $set An associative array of insert values. + * @param bool|null $escape Whether to escape values. + * @param int $batchSize The size of the batch to run. + * @param bool $testing `true` means only number of records is returned, `false` will execute the query. * - * @return bool|int Number of rows inserted or FALSE on failure + * @return false|int|list Number of rows affected or `false` on failure, SQL array when test mode */ abstract protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false); /** * Updates a single record in the database. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param array|int|string|null $id ID - * @param array|null $row Row data - * @phpstan-param row_array|null $row + * @param int|list|string|null $id + * @param row_array|null $row */ abstract protected function doUpdate($id = null, $row = null): bool; /** * Compiles an update and runs the query. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param array|null $set An associative array of update values - * @param string|null $index The where key - * @param int $batchSize The size of the batch to run - * @param bool $returnSQL True means SQL is returned, false will execute the query + * @param list|null $set An associative array of update values. + * @param string|null $index The where key. + * @param int $batchSize The size of the batch to run. + * @param bool $returnSQL `true` means SQL is returned, `false` will execute the query. * - * @return false|int|list Number of rows affected or FALSE on failure, SQL array when testMode + * @return false|int|list Number of rows affected or `false` on failure, SQL array when test mode * * @throws DatabaseException */ abstract protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false); /** - * Deletes a single record from the database where $id matches. - * This method works only with dbCalls. + * Deletes a single record from the database where $id matches + * the table's primary key. + * This method works only with DB calls. * - * @param array|int|string|null $id The rows primary key(s) - * @param bool $purge Allows overriding the soft deletes setting. + * @param int|list|string|null $id The rows primary key(s). + * @param bool $purge Allows overriding the soft deletes setting. * - * @return bool|string + * @return bool|string Returns a SQL string if in test mode. * * @throws DatabaseException */ abstract protected function doDelete($id = null, bool $purge = false); /** - * Permanently deletes all rows that have been marked as deleted. - * through soft deletes (deleted = 1). - * This method works only with dbCalls. + * Permanently deletes all rows that have been marked as deleted + * through soft deletes (value of column $deletedField is not null). + * This method works only with DB calls. * - * @return bool|string Returns a string if in test mode. + * @return bool|string Returns a SQL string if in test mode. */ abstract protected function doPurgeDeleted(); /** - * Works with the find* methods to return only the rows that - * have been deleted. - * This method works only with dbCalls. + * Works with the $this->find* methods to return only the rows that + * have been deleted (value of column $deletedField is not null). + * This method works only with DB calls. * * @return void */ @@ -531,10 +532,10 @@ abstract protected function doOnlyDeleted(); /** * Compiles a replace and runs the query. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param row_array|null $row Row data - * @param bool $returnSQL Set to true to return Query String + * @param row_array|null $row + * @param bool $returnSQL `true` means SQL is returned, `false` will execute the query. * * @return BaseResult|false|Query|string */ @@ -542,39 +543,40 @@ abstract protected function doReplace(?array $row = null, bool $returnSQL = fals /** * Grabs the last error(s) that occurred from the Database connection. - * This method works only with dbCalls. + * This method works only with DB calls. * * @return array */ abstract protected function doErrors(); /** - * Public getter to return the id value using the idValue() method. - * For example with SQL this will return $data->$this->primaryKey. + * Public getter to return the ID value for the data array or object. + * For example with SQL this will return `$data->{$this->primaryKey}`. * - * @param object|row_array $row Row data + * @param object|row_array $row * - * @return array|int|string|null + * @return int|string|null */ abstract public function getIdValue($row); /** * Override countAllResults to account for soft deleted accounts. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param bool $reset Reset - * @param bool $test Test + * @param bool $reset When `false`, the `$tempUseSoftDeletes` will be + * dependent on `$useSoftDeletes` value because we don't + * want to add the same "where" condition for the second time. + * @param bool $test `true` returns the number of all records, `false` will execute the query. * - * @return int|string + * @return int|string Returns a SQL string if in test mode. */ abstract public function countAllResults(bool $reset = true, bool $test = false); /** * Loops over records in batches, allowing you to operate on them. - * This method works only with dbCalls. + * This method works only with DB calls. * - * @param int $size Size - * @param Closure(array|object): mixed $userFunc Callback Function + * @param Closure(array|object): mixed $userFunc * * @return void * @@ -585,10 +587,9 @@ abstract public function chunk(int $size, Closure $userFunc); /** * Fetches the row of database. * - * @param array|int|string|null $id One primary key or an array of primary keys + * @param int|list|string|null $id One primary key or an array of primary keys. * - * @return array|object|null The resulting row of data, or null. - * @phpstan-return ($id is int|string ? row_array|object|null : list) + * @return ($id is int|string ? object|row_array|null : list) */ public function find($id = null) { @@ -628,9 +629,7 @@ public function find($id = null) /** * Fetches the column of database. * - * @param string $columnName Column Name - * - * @return array|null The resulting row of data, or null if no data found. + * @return list|object|string|null>|null The resulting row of data, or `null` if no data found. * * @throws DataException */ @@ -648,10 +647,7 @@ public function findColumn(string $columnName) /** * Fetches all results, while optionally limiting them. * - * @param int $limit Limit - * @param int $offset Offset - * - * @return array + * @return list */ public function findAll(?int $limit = null, int $offset = 0) { @@ -696,7 +692,7 @@ public function findAll(?int $limit = null, int $offset = 0) /** * Returns the first row of the result set. * - * @return array|object|null + * @return object|row_array|null */ public function first() { @@ -731,12 +727,14 @@ public function first() /** * A convenience method that will attempt to determine whether the - * data should be inserted or updated. Will work with either - * an array or object. When using with custom class objects, + * data should be inserted or updated. + * + * Will work with either an array or object. + * When using with custom class objects, * you must ensure that the class will provide access to the class * variables, even if through a magic method. * - * @param object|row_array $row Row data + * @param object|row_array $row * * @throws ReflectionException */ @@ -761,10 +759,9 @@ public function save($row): bool /** * This method is called on save to determine if entry have to be updated. - * If this method returns false insert operation will be executed + * If this method returns `false` insert operation will be executed. * - * @param array|object $row Row data - * @phpstan-param row_array|object $row + * @param object|row_array $row */ protected function shouldUpdate($row): bool { @@ -787,7 +784,7 @@ public function getInsertID() * Inserts data into the database. If an object is provided, * it will attempt to convert it to an array. * - * @param object|row_array|null $row Row data + * @param object|row_array|null $row * @param bool $returnID Whether insert ID should be returned or not. * * @return ($returnID is true ? false|int|string : bool) @@ -864,7 +861,9 @@ public function insert($row = null, bool $returnID = true) * Set datetime to created field. * * @param row_array $row - * @param int|string $date timestamp or datetime string + * @param int|string $date Timestamp or datetime string. + * + * @return row_array */ protected function setCreatedField(array $row, $date): array { @@ -879,7 +878,9 @@ protected function setCreatedField(array $row, $date): array * Set datetime to updated field. * * @param row_array $row - * @param int|string $date timestamp or datetime string + * @param int|string $date Timestamp or datetime string + * + * @return row_array */ protected function setUpdatedField(array $row, $date): array { @@ -893,12 +894,12 @@ protected function setUpdatedField(array $row, $date): array /** * Compiles batch insert runs the queries, validating each row prior. * - * @param list|null $set an associative array of insert values - * @param bool|null $escape Whether to escape values - * @param int $batchSize The size of the batch to run - * @param bool $testing True means only number of records is returned, false will execute the query + * @param list|null $set An associative array of insert values. + * @param bool|null $escape Whether to escape values. + * @param int $batchSize The size of the batch to run. + * @param bool $testing `true` means only number of records is returned, `false` will execute the query. * - * @return bool|int Number of rows inserted or FALSE on failure + * @return false|int|list Number of rows inserted or `false` on failure. * * @throws ReflectionException */ @@ -961,9 +962,8 @@ public function insertBatch(?array $set = null, ?bool $escape = null, int $batch * Updates a single record in the database. If an object is provided, * it will attempt to convert it into an array. * - * @param array|int|string|null $id - * @param array|object|null $row Row data - * @phpstan-param row_array|object|null $row + * @param int|list|string|null $id + * @param object|row_array|null $row * * @throws ReflectionException */ @@ -1023,12 +1023,12 @@ public function update($id = null, $row = null): bool /** * Compiles an update and runs the query. * - * @param list|null $set an associative array of insert values - * @param string|null $index The where key - * @param int $batchSize The size of the batch to run - * @param bool $returnSQL True means SQL is returned, false will execute the query + * @param list|null $set An associative array of insert values. + * @param string|null $index The where key. + * @param int $batchSize The size of the batch to run. + * @param bool $returnSQL `true` means SQL is returned, `false` will execute the query. * - * @return false|int|list Number of rows affected or FALSE on failure, SQL array when testMode + * @return false|int|list Number of rows affected or `false` on failure, SQL array when test mode. * * @throws DatabaseException * @throws ReflectionException @@ -1091,10 +1091,10 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc /** * Deletes a single record from the database where $id matches. * - * @param int|list|string|null $id The rows primary key(s) + * @param int|list|string|null $id The rows primary key(s). * @param bool $purge Allows overriding the soft deletes setting. * - * @return BaseResult|bool + * @return bool|string Returns a SQL string if in test mode. * * @throws DatabaseException */ @@ -1135,9 +1135,9 @@ public function delete($id = null, bool $purge = false) /** * Permanently deletes all rows that have been marked as deleted - * through soft deletes (deleted = 1). + * through soft deletes (value of column $deletedField is not null). * - * @return bool|string Returns a string if in test mode. + * @return bool|string Returns a SQL string if in test mode. */ public function purgeDeleted() { @@ -1152,8 +1152,6 @@ public function purgeDeleted() * Sets $useSoftDeletes value so that we can temporarily override * the soft deletes settings. Can be used for all find* methods. * - * @param bool $val Value - * * @return $this */ public function withDeleted(bool $val = true) @@ -1164,7 +1162,7 @@ public function withDeleted(bool $val = true) } /** - * Works with the find* methods to return only the rows that + * Works with the $this->find* methods to return only the rows that * have been deleted. * * @return $this @@ -1180,8 +1178,8 @@ public function onlyDeleted() /** * Compiles a replace and runs the query. * - * @param row_array|null $row Row data - * @param bool $returnSQL Set to true to return Query String + * @param row_array|null $row + * @param bool $returnSQL `true` means SQL is returned, `false` will execute the query. * * @return BaseResult|false|Query|string */ @@ -1200,14 +1198,15 @@ public function replace(?array $row = null, bool $returnSQL = false) } /** - * Grabs the last error(s) that occurred. If data was validated, - * it will first check for errors there, otherwise will try to - * grab the last error from the Database connection. + * Grabs the last error(s) that occurred. + * + * If data was validated, it will first check for errors there, + * otherwise will try to grab the last error from the Database connection. * * The return array should be in the following format: - * ['source' => 'message'] + * `['source' => 'message']`. * - * @param bool $forceDB Always grab the db error, not validation + * @param bool $forceDB Always grab the DB error, not validation. * * @return array */ @@ -1230,12 +1229,12 @@ public function errors(bool $forceDB = false) * Expects a GET variable (?page=2) that specifies the page of results * to display. * - * @param int|null $perPage Items per page + * @param int|null $perPage Items per page. * @param string $group Will be used by the pagination library to identify a unique pagination set. - * @param int|null $page Optional page number (useful when the page number is provided in different way) - * @param int $segment Optional URI segment number (if page number is provided by URI segment) + * @param int|null $page Optional page number (useful when the page number is provided in different way). + * @param int $segment Optional URI segment number (if page number is provided by URI segment). * - * @return array|null + * @return list */ public function paginate(?int $perPage = null, string $group = 'default', ?int $page = null, int $segment = 0) { @@ -1258,7 +1257,7 @@ public function paginate(?int $perPage = null, string $group = 'default', ?int $ /** * It could be used when you have to change default or override current allowed fields. * - * @param array $allowedFields Array with names of fields + * @param list $allowedFields Array with names of fields. * * @return $this */ @@ -1273,8 +1272,6 @@ public function setAllowedFields(array $allowedFields) * Sets whether or not we should whitelist data set during * updates or inserts against $this->availableFields. * - * @param bool $protect Value - * * @return $this */ public function protect(bool $protect = true) @@ -1291,8 +1288,9 @@ public function protect(bool $protect = true) * @used-by update() to protect against mass assignment vulnerabilities. * @used-by updateBatch() to protect against mass assignment vulnerabilities. * - * @param array $row Row data - * @phpstan-param row_array $row + * @param row_array $row + * + * @return row_array * * @throws DataException */ @@ -1322,8 +1320,9 @@ protected function doProtectFields(array $row): array * @used-by insert() to protect against mass assignment vulnerabilities. * @used-by insertBatch() to protect against mass assignment vulnerabilities. * - * @param array $row Row data - * @phpstan-param row_array $row + * @param row_array $row + * + * @return row_array * * @throws DataException */ @@ -1333,17 +1332,17 @@ protected function doProtectFieldsForInsert(array $row): array } /** - * Sets the date or current date if null value is passed. + * Sets the timestamp or current timestamp if null value is passed. * - * @param int|null $userData An optional PHP timestamp to be converted. + * @param int|null $userDate An optional PHP timestamp to be converted * * @return int|string * * @throws ModelException */ - protected function setDate(?int $userData = null) + protected function setDate(?int $userDate = null) { - $currentDate = $userData ?? Time::now()->getTimestamp(); + $currentDate = $userDate ?? Time::now()->getTimestamp(); return $this->intToDate($currentDate); } @@ -1355,12 +1354,10 @@ protected function setDate(?int $userData = null) * used by inheriting classes. * * The available time formats are: - * - 'int' - Stores the date as an integer timestamp - * - 'datetime' - Stores the data in the SQL datetime format + * - 'int' - Stores the date as an integer timestamp. + * - 'datetime' - Stores the data in the SQL datetime format. * - 'date' - Stores the date (only) in the SQL date format. * - * @param int $value value - * * @return int|string * * @throws ModelException @@ -1379,12 +1376,10 @@ protected function intToDate(int $value) * Converts Time value to string using $this->dateFormat. * * The available time formats are: - * - 'int' - Stores the date as an integer timestamp - * - 'datetime' - Stores the data in the SQL datetime format + * - 'int' - Stores the date as an integer timestamp. + * - 'datetime' - Stores the data in the SQL datetime format. * - 'date' - Stores the date (only) in the SQL date format. * - * @param Time $value value - * * @return int|string */ protected function timeToDate(Time $value) @@ -1398,9 +1393,7 @@ protected function timeToDate(Time $value) } /** - * Set the value of the skipValidation flag. - * - * @param bool $skip Value + * Set the value of the $skipValidation flag. * * @return $this */ @@ -1415,7 +1408,7 @@ public function skipValidation(bool $skip = true) * Allows to set (and reset) validation messages. * It could be used when you have to change default or override current validate messages. * - * @param array $validationMessages Value + * @param array> $validationMessages * * @return $this */ @@ -1430,8 +1423,7 @@ public function setValidationMessages(array $validationMessages) * Allows to set field wise validation message. * It could be used when you have to change default or override current validate messages. * - * @param string $field Field Name - * @param array $fieldMessages Validation messages + * @param array $fieldMessages * * @return $this */ @@ -1446,7 +1438,7 @@ public function setValidationMessage(string $field, array $fieldMessages) * Allows to set (and reset) validation rules. * It could be used when you have to change default or override current validate rules. * - * @param array|string>|string> $validationRules Value + * @param array|string>|string> $validationRules * * @return $this */ @@ -1461,8 +1453,7 @@ public function setValidationRules(array $validationRules) * Allows to set field wise validation rules. * It could be used when you have to change default or override current validate rules. * - * @param string $field Field Name - * @param array|string $fieldRules Validation rules + * @param array|string>|string $fieldRules * * @return $this */ @@ -1490,8 +1481,6 @@ public function setValidationRule(string $field, $fieldRules) * Should validation rules be removed before saving? * Most handy when doing updates. * - * @param bool $choice Value - * * @return $this */ public function cleanRules(bool $choice = false) @@ -1505,8 +1494,7 @@ public function cleanRules(bool $choice = false) * Validate the row data against the validation rules (or the validation group) * specified in the class property, $validationRules. * - * @param array|object $row Row data - * @phpstan-param row_array|object $row + * @param object|row_array $row */ public function validate($row): bool { @@ -1548,7 +1536,9 @@ public function validate($row): bool * Returns the model's defined validation rules so that they * can be used elsewhere, if needed. * - * @param array $options Options + * @param array{only?: list, except?: list} $options Filter the list of rules + * + * @return array|string>|string> */ public function getValidationRules(array $options = []): array { @@ -1583,6 +1573,8 @@ protected function ensureValidation(): void /** * Returns the model's validation messages, so they * can be used elsewhere, if needed. + * + * @return array> */ public function getValidationMessages(): array { @@ -1594,13 +1586,14 @@ public function getValidationMessages(): array * currently so that rules don't block updating when only updating * a partial row. * - * @param array $rules Array containing field name and rule - * @param array $row Row data (@TODO Remove null in param type) - * @phpstan-param row_array $row + * @param array|string>|string> $rules + * @param row_array $row + * + * @return array|string>|string> */ - protected function cleanValidationRules(array $rules, ?array $row = null): array + protected function cleanValidationRules(array $rules, array $row): array { - if ($row === null || $row === []) { + if ($row === []) { return []; } @@ -1617,8 +1610,6 @@ protected function cleanValidationRules(array $rules, ?array $row = null): array * Sets $tempAllowCallbacks value so that we can temporarily override * the setting. Resets after the next method that uses triggers. * - * @param bool $val value - * * @return $this */ public function allowCallbacks(bool $val = true) @@ -1643,10 +1634,12 @@ public function allowCallbacks(bool $val = true) * * If callbacks are not allowed then returns $eventData immediately. * - * @param string $event Event - * @param array $eventData Event Data + * @template TEventData of array * - * @return array + * @param string $event Valid property of the model event: $this->before*, $this->after*, etc. + * @param TEventData $eventData + * + * @return TEventData * * @throws DataException */ @@ -1686,7 +1679,7 @@ public function asArray() * class vars with the same name as the collection columns, * or at least allows them to be created. * - * @param 'object'|class-string $class Class Name + * @param 'object'|class-string $class * * @return $this */ @@ -1700,12 +1693,12 @@ public function asObject(string $class = 'object') /** * Takes a class and returns an array of its public and protected * properties as an array suitable for use in creates and updates. - * This method uses objectToRawArray() internally and does conversion - * to string on all Time instances + * This method uses `$this->objectToRawArray()` internally and does conversion + * to string on all Time instances. * - * @param object $object Object - * @param bool $onlyChanged Only Changed Property - * @param bool $recursive If true, inner entities will be cast as array as well + * @param object $object + * @param bool $onlyChanged Returns only the changed properties. + * @param bool $recursive If `true`, inner entities will be cast as array as well. * * @return array * @@ -1745,17 +1738,17 @@ protected function timeToString(array $properties): array * Takes a class and returns an array of its public and protected * properties as an array with raw values. * - * @param object $object Object - * @param bool $onlyChanged Only Changed Property - * @param bool $recursive If true, inner entities will be casted as array as well + * @param object $object + * @param bool $onlyChanged Returns only the changed properties. + * @param bool $recursive If `true`, inner entities will be cast as array as well. * - * @return array Array with raw values. + * @return array Array with raw values * * @throws ReflectionException */ protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array { - // Entity::toRawArray() returns array. + // Entity::toRawArray() returns array if (method_exists($object, 'toRawArray')) { $properties = $object->toRawArray($onlyChanged, $recursive); } else { @@ -1765,7 +1758,7 @@ protected function objectToRawArray($object, bool $onlyChanged = true, bool $rec $properties = []; // Loop over each property, - // saving the name/value in a new array we can return. + // saving the name/value in a new array we can return foreach ($props as $prop) { $properties[$prop->getName()] = $prop->getValue($object); } @@ -1777,8 +1770,9 @@ protected function objectToRawArray($object, bool $onlyChanged = true, bool $rec /** * Transform data to array. * - * @param object|row_array|null $row Row data - * @param string $type Type of data (insert|update) + * @param object|row_array|null $row + * + * @return array * * @throws DataException * @throws InvalidArgumentException @@ -1842,11 +1836,9 @@ protected function transformDataToArray($row, string $type): array } /** - * Provides the db connection and model's properties. - * - * @param string $name Name + * Provides the DB connection and model's properties. * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function __get(string $name) { @@ -1858,9 +1850,7 @@ public function __get(string $name) } /** - * Checks for the existence of properties across this model, and db connection. - * - * @param string $name Name + * Checks for the existence of properties across this model, and DB connection. */ public function __isset(string $name): bool { @@ -1874,10 +1864,9 @@ public function __isset(string $name): bool /** * Provides direct access to method in the database connection. * - * @param string $name Name - * @param array $params Params + * @param array $params * - * @return $this|null + * @return mixed */ public function __call(string $name, array $params) { @@ -1901,8 +1890,10 @@ public function allowEmptyInserts(bool $value = true): self /** * Converts database data array to return type value. * - * @param array $row Raw data from database + * @param array $row Raw data from database. * @param 'array'|'object'|class-string $returnType + * + * @return array|object */ protected function convertToReturnType(array $row, string $returnType): array|object { diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index fd33bf40a566..f38d52dc9750 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -947,8 +947,8 @@ public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape * @used-by whereNotIn() * @used-by orWhereNotIn() * - * @param non-empty-string|null $key - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param non-empty-string|null $key + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list|null $values The values searched on, or anonymous function with subquery * * @return $this * diff --git a/system/Model.php b/system/Model.php index 60759b9e201f..108ff17cf849 100644 --- a/system/Model.php +++ b/system/Model.php @@ -16,18 +16,15 @@ use Closure; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseConnection; -use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; -use CodeIgniter\Database\Query; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\ModelException; use CodeIgniter\Validation\ValidationInterface; use Config\Database; use Config\Feature; -use ReflectionException; use stdClass; /** @@ -91,7 +88,7 @@ class Model extends BaseModel { /** - * Name of database table + * Name of database table. * * @var string */ @@ -112,7 +109,7 @@ class Model extends BaseModel protected $useAutoIncrement = true; /** - * Query Builder object + * Query Builder object. * * @var BaseBuilder|null */ @@ -123,8 +120,7 @@ class Model extends BaseModel * so that we can capture it (not the builder) * and ensure it gets validated first. * - * @var array{escape: array, data: array}|array{} - * @phpstan-var array{escape: array, data: row_array}|array{} + * @var array{escape: array, data: row_array}|array{} */ protected $tempData = []; @@ -132,14 +128,14 @@ class Model extends BaseModel * Escape array that maps usage of escape * flag for every parameter. * - * @var array + * @var array */ protected $escape = []; /** * Builder method names that should not be used in the Model. * - * @var list method name + * @var list */ private array $builderMethodsNotAvailable = [ 'getCompiledInsert', @@ -160,9 +156,7 @@ public function __construct(?ConnectionInterface $db = null, ?ValidationInterfac } /** - * Specify the table associated with a model - * - * @param string $table Table + * Specify the table associated with a model. * * @return $this */ @@ -173,22 +167,11 @@ public function setTable(string $table) return $this; } - /** - * Fetches the row(s) of database from $this->table with a primary key - * matching $id. - * This method works only with dbCalls. - * - * @param bool $singleton Single or multiple results - * @param array|int|string|null $id One primary key or an array of primary keys - * - * @return array|object|null The resulting row of data, or null. - * @phpstan-return ($singleton is true ? row_array|null|object : list) - */ protected function doFind(bool $singleton, $id = null) { $builder = $this->builder(); - $useCast = $this->useCasts(); + if ($useCast) { $returnType = $this->tempReturnType; $this->asArray(); @@ -238,30 +221,15 @@ protected function doFind(bool $singleton, $id = null) return $rows; } - /** - * Fetches the column of database from $this->table. - * This method works only with dbCalls. - * - * @param string $columnName Column Name - * - * @return array|null The resulting row of data, or null if no data found. - * @phpstan-return list|null - */ protected function doFindColumn(string $columnName) { return $this->select($columnName)->asArray()->find(); } /** - * Works with the current Query Builder instance to return - * all results, while optionally limiting them. - * This method works only with dbCalls. - * - * @param int|null $limit Limit - * @param int $offset Offset + * {@inheritDoc} * - * @return array - * @phpstan-return list + * Works with the current Query Builder instance. */ protected function doFindAll(?int $limit = null, int $offset = 0) { @@ -298,12 +266,10 @@ protected function doFindAll(?int $limit = null, int $offset = 0) } /** - * Returns the first row of the result set. Will take any previous - * Query Builder calls into account when determining the result set. - * This method works only with dbCalls. + * {@inheritDoc} * - * @return array|object|null - * @phpstan-return row_array|object|null + * Will take any previous Query Builder calls into account + * when determining the result set. */ protected function doFirst() { @@ -338,15 +304,6 @@ protected function doFirst() return $row; } - /** - * Inserts data into the current table. - * This method works only with dbCalls. - * - * @param array $row Row data - * @phpstan-param row_array $row - * - * @return bool - */ protected function doInsert(array $row) { $escape = $this->escape; @@ -402,24 +359,13 @@ protected function doInsert(array $row) return $result; } - /** - * Compiles batch insert strings and runs the queries, validating each row prior. - * This method works only with dbCalls. - * - * @param array|null $set An associative array of insert values - * @param bool|null $escape Whether to escape values - * @param int $batchSize The size of the batch to run - * @param bool $testing True means only number of records is returned, false will execute the query - * - * @return bool|int Number of rows inserted or FALSE on failure - */ protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false) { - if (is_array($set)) { + if (is_array($set) && ! $this->useAutoIncrement) { foreach ($set as $row) { - // Require non-empty primaryKey when + // Require non-empty $primaryKey when // not using auto-increment feature - if (! $this->useAutoIncrement && ! isset($row[$this->primaryKey])) { + if (! isset($row[$this->primaryKey])) { throw DataException::forEmptyPrimaryKey('insertBatch'); } } @@ -428,14 +374,6 @@ protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $ return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize); } - /** - * Updates a single record in $this->table. - * This method works only with dbCalls. - * - * @param array|int|string|null $id - * @param array|null $row Row data - * @phpstan-param row_array|null $row - */ protected function doUpdate($id = null, $row = null): bool { $escape = $this->escape; @@ -461,36 +399,11 @@ protected function doUpdate($id = null, $row = null): bool return $builder->update(); } - /** - * Compiles an update string and runs the query - * This method works only with dbCalls. - * - * @param array|null $set An associative array of update values - * @param string|null $index The where key - * @param int $batchSize The size of the batch to run - * @param bool $returnSQL True means SQL is returned, false will execute the query - * - * @return false|int|list Number of rows affected or FALSE on failure, SQL array when testMode - * - * @throws DatabaseException - */ protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false) { return $this->builder()->testMode($returnSQL)->updateBatch($set, $index, $batchSize); } - /** - * Deletes a single record from $this->table where $id matches - * the table's primaryKey - * This method works only with dbCalls. - * - * @param array|int|string|null $id The rows primary key(s) - * @param bool $purge Allows overriding the soft deletes setting. - * - * @return bool|string SQL string when testMode - * - * @throws DatabaseException - */ protected function doDelete($id = null, bool $purge = false) { $set = []; @@ -521,13 +434,6 @@ protected function doDelete($id = null, bool $purge = false) return $builder->delete(); } - /** - * Permanently deletes all rows that have been marked as deleted - * through soft deletes (deleted = 1) - * This method works only with dbCalls. - * - * @return bool|string Returns a SQL string if in test mode. - */ protected function doPurgeDeleted() { return $this->builder() @@ -535,39 +441,22 @@ protected function doPurgeDeleted() ->delete(); } - /** - * Works with the find* methods to return only the rows that - * have been deleted. - * This method works only with dbCalls. - * - * @return void - */ protected function doOnlyDeleted() { $this->builder()->where($this->table . '.' . $this->deletedField . ' IS NOT NULL'); } - /** - * Compiles a replace into string and runs the query - * This method works only with dbCalls. - * - * @param row_array|null $row Data - * @param bool $returnSQL Set to true to return Query String - * - * @return BaseResult|false|Query|string - */ protected function doReplace(?array $row = null, bool $returnSQL = false) { return $this->builder()->testMode($returnSQL)->replace($row); } /** - * Grabs the last error(s) that occurred from the Database connection. + * {@inheritDoc} + * * The return array should be in the following format: - * ['source' => 'message'] + * `['source' => 'message']`. * This method works only with dbCalls. - * - * @return array */ protected function doErrors() { @@ -581,14 +470,6 @@ protected function doErrors() return [$this->db::class => $error['message']]; } - /** - * Returns the id value for the data array or object - * - * @param array|object $row Row data - * @phpstan-param row_array|object $row - * - * @return array|int|string|null - */ public function getIdValue($row) { if (is_object($row)) { @@ -619,9 +500,26 @@ public function getIdValue($row) return null; } + public function countAllResults(bool $reset = true, bool $test = false) + { + if ($this->tempUseSoftDeletes) { + $this->builder()->where($this->table . '.' . $this->deletedField, null); + } + + // When $reset === false, the $tempUseSoftDeletes will be + // dependent on $useSoftDeletes value because we don't + // want to add the same "where" condition for the second time. + $this->tempUseSoftDeletes = $reset + ? $this->useSoftDeletes + : ($this->useSoftDeletes ? false : $this->useSoftDeletes); + + return $this->builder()->testMode($test)->countAllResults($reset); + } + /** - * Loops over records in batches, allowing you to operate on them. - * Works with $this->builder to get the Compiled select to + * {@inheritDoc} + * + * Works with `$this->builder` to get the Compiled select to * determine the rows to operate on. * This method works only with dbCalls. */ @@ -654,27 +552,6 @@ public function chunk(int $size, Closure $userFunc) } } - /** - * Override countAllResults to account for soft deleted accounts. - * - * @return int|string - */ - public function countAllResults(bool $reset = true, bool $test = false) - { - if ($this->tempUseSoftDeletes) { - $this->builder()->where($this->table . '.' . $this->deletedField, null); - } - - // When $reset === false, the $tempUseSoftDeletes will be - // dependent on $useSoftDeletes value because we don't - // want to add the same "where" condition for the second time - $this->tempUseSoftDeletes = $reset - ? $this->useSoftDeletes - : ($this->useSoftDeletes ? false : $this->useSoftDeletes); - - return $this->builder()->testMode($test)->countAllResults($reset); - } - /** * Provides a shared instance of the Query Builder. * @@ -725,7 +602,7 @@ 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 array|object|string $key Field name, or an array of field/value pairs, or an object + * @param object|row_array|string $key Field name, or an array of field/value pairs, or an object * @param bool|float|int|object|string|null $value Field value, if $key is a single field * @param bool|null $escape Whether to escape values * @@ -748,12 +625,6 @@ public function set($key, $value = '', ?bool $escape = null) return $this; } - /** - * This method is called on save to determine if entry have to be updated - * If this method return false insert operation will be executed - * - * @param array|object $row Data - */ protected function shouldUpdate($row): bool { if (parent::shouldUpdate($row) === false) { @@ -769,17 +640,6 @@ protected function shouldUpdate($row): bool return $this->where($this->primaryKey, $this->getIdValue($row))->countAllResults() === 1; } - /** - * Inserts data into the database. If an object is provided, - * it will attempt to convert it to an array. - * - * @param object|row_array|null $row - * @param bool $returnID Whether insert ID should be returned or not. - * - * @return ($returnID is true ? false|int|string : bool) - * - * @throws ReflectionException - */ public function insert($row = null, bool $returnID = true) { if (isset($this->tempData['data'])) { @@ -797,18 +657,6 @@ public function insert($row = null, bool $returnID = true) return parent::insert($row, $returnID); } - /** - * Ensures that only the fields that are allowed to be inserted are in - * the data array. - * - * @used-by insert() to protect against mass assignment vulnerabilities. - * @used-by insertBatch() to protect against mass assignment vulnerabilities. - * - * @param array $row Row data - * @phpstan-param row_array $row - * - * @throws DataException - */ protected function doProtectFieldsForInsert(array $row): array { if (! $this->protectFields) { @@ -833,16 +681,6 @@ protected function doProtectFieldsForInsert(array $row): array return $row; } - /** - * Updates a single record in the database. If an object is provided, - * it will attempt to convert it into an array. - * - * @param array|int|string|null $id - * @param array|object|null $row - * @phpstan-param row_array|object|null $row - * - * @throws ReflectionException - */ public function update($id = null, $row = null): bool { if (isset($this->tempData['data'])) { @@ -860,17 +698,6 @@ public function update($id = null, $row = null): bool return parent::update($id, $row); } - /** - * Takes a class and returns an array of its public and protected - * properties as an array with raw values. - * - * @param object $object Object - * @param bool $recursive If true, inner entities will be cast as array as well - * - * @return array Array with raw values. - * - * @throws ReflectionException - */ protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array { return parent::objectToRawArray($object, $onlyChanged); @@ -879,9 +706,7 @@ protected function objectToRawArray($object, bool $onlyChanged = true, bool $rec /** * Provides/instantiates the builder/db connection and model's table/primary key names and return type. * - * @param string $name Name - * - * @return array|BaseBuilder|bool|float|int|object|string|null + * @return array|BaseBuilder|bool|float|int|object|string|null */ public function __get(string $name) { @@ -894,8 +719,6 @@ public function __get(string $name) /** * Checks for the existence of properties across this model, builder, and db connection. - * - * @param string $name Name */ public function __isset(string $name): bool { @@ -910,7 +733,7 @@ public function __isset(string $name): bool * Provides direct access to method in the builder (if available) * and the database connection. * - * @return $this|array|BaseBuilder|bool|float|int|object|string|null + * @return $this|array|BaseBuilder|bool|float|int|object|string|null */ public function __call(string $name, array $params) { diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index 1dc7249c3efd..a9380f34c771 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -561,7 +561,7 @@ public function testUpdateThrowDatabaseExceptionWithoutWhereClause($id, string $ // $useSoftDeletes = false $this->createModel(JobModel::class); - $this->model->update($id, ['name' => 'Foo Bar']); + $this->model->update($id, ['name' => 'Foo Bar']); // @phpstan-ignore argument.type } public static function provideUpdateThrowDatabaseExceptionWithoutWhereClause(): iterable diff --git a/tests/system/Models/ValidationModelRuleGroupTest.php b/tests/system/Models/ValidationModelRuleGroupTest.php index eb50406ab62b..37e0ae4e3e6e 100644 --- a/tests/system/Models/ValidationModelRuleGroupTest.php +++ b/tests/system/Models/ValidationModelRuleGroupTest.php @@ -201,7 +201,7 @@ public function testCleanValidationRemovesAllWhenNoDataProvided(): void 'foo' => 'bar', ]; - $rules = $cleaner($rules, null); + $rules = $cleaner($rules, []); $this->assertEmpty($rules); } diff --git a/tests/system/Models/ValidationModelTest.php b/tests/system/Models/ValidationModelTest.php index 4a99d8fae5bb..7ec08c9e36ac 100644 --- a/tests/system/Models/ValidationModelTest.php +++ b/tests/system/Models/ValidationModelTest.php @@ -189,7 +189,7 @@ public function testCleanValidationRemovesAllWhenNoDataProvided(): void 'foo' => 'bar', ]; - $rules = $cleaner($rules, null); + $rules = $cleaner($rules, []); $this->assertEmpty($rules); } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 5a3d9a401b2c..bbc09fc8d60f 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -49,6 +49,8 @@ Interface Changes Method Signature Changes ======================== +- **BaseModel:** The type of the ``$row`` parameter for the ``cleanValidationRules()`` method has been changed from ``?array $row = null`` to ``array $row``. + - Added the ``SensitiveParameter`` attribute to various methods to conceal sensitive information from stack traces. Affected methods are: - ``CodeIgniter\Encryption\EncrypterInterface::encrypt()`` - ``CodeIgniter\Encryption\EncrypterInterface::decrypt()`` diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 182aed11ac0b..c9be9878e094 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -765,9 +765,9 @@ The other way to set the validation message to fields by functions, .. literalinclude:: model/030.php -.. php:method:: setValidationMessages($fieldMessages) +.. php:method:: setValidationMessages($validationMessages) - :param array $fieldMessages: + :param array $validationMessages: This function will set the field messages. diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index 0c99d09b54e3..91faa4168630 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,4 +1,4 @@ -# total 85 errors +# total 84 errors parameters: ignoreErrors: @@ -28,12 +28,12 @@ parameters: path: ../../system/Database/SQLite3/Builder.php - - message: '#^Parameter \#2 \$to of method CodeIgniter\\Router\\RouteCollection\:\:add\(\) expects array\|\(Closure\(mixed \.\.\.\)\: \(CodeIgniter\\HTTP\\ResponseInterface\|string\|void\)\)\|string, Closure\(mixed\)\: \(CodeIgniter\\HTTP\\DownloadResponse\|null\) given\.$#' + message: '#^Parameter \#2 \$to of method CodeIgniter\\Router\\RouteCollection\:\:add\(\) expects array\|\(Closure\(mixed \.\.\.\)\: \(CodeIgniter\\HTTP\\ResponseInterface\|string\|void\)\)\|string, Closure\(mixed\)\: CodeIgniter\\HTTP\\ResponseInterface given\.$#' count: 1 path: ../../tests/system/CodeIgniterTest.php - - message: '#^Parameter \#2 \$to of method CodeIgniter\\Router\\RouteCollection\:\:add\(\) expects array\|\(Closure\(mixed \.\.\.\)\: \(CodeIgniter\\HTTP\\ResponseInterface\|string\|void\)\)\|string, Closure\(mixed\)\: CodeIgniter\\HTTP\\ResponseInterface given\.$#' + message: '#^Parameter \#2 \$to of method CodeIgniter\\Router\\RouteCollection\:\:add\(\) expects array\|\(Closure\(mixed \.\.\.\)\: \(CodeIgniter\\HTTP\\ResponseInterface\|string\|void\)\)\|string, Closure\(mixed\)\: \(CodeIgniter\\HTTP\\DownloadResponse\|null\) given\.$#' count: 1 path: ../../tests/system/CodeIgniterTest.php @@ -197,11 +197,6 @@ parameters: count: 1 path: ../../tests/system/Models/DataConverterModelTest.php - - - message: '#^Parameter \#1 \$id of method CodeIgniter\\Model\:\:update\(\) expects array\|int\|string\|null, false\|null given\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Parameter \#1 \$format of method CodeIgniter\\RESTful\\ResourceController\:\:setFormat\(\) expects ''json''\|''xml'', ''Nonsense'' given\.$#' count: 1 diff --git a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon b/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon index 6a5f6dccbf23..71e76f0cfe41 100644 --- a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon +++ b/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon @@ -338,12 +338,12 @@ parameters: path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Assigning ''fr\-FR; q\=1\.0, en; q\=0\.5'' directly on offset ''HTTP_ACCEPT_LANGUAGE'' of \$_SERVER is discouraged\.$#' + message: '#^Assigning ''fr; q\=1\.0, en; q\=0\.5'' directly on offset ''HTTP_ACCEPT_LANGUAGE'' of \$_SERVER is discouraged\.$#' count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Assigning ''fr; q\=1\.0, en; q\=0\.5'' directly on offset ''HTTP_ACCEPT_LANGUAGE'' of \$_SERVER is discouraged\.$#' + message: '#^Assigning ''fr\-FR; q\=1\.0, en; q\=0\.5'' directly on offset ''HTTP_ACCEPT_LANGUAGE'' of \$_SERVER is discouraged\.$#' count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 5d96dc562b91..50220a8e8989 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2760 errors +# total 2708 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/method.childReturnType.neon b/utils/phpstan-baseline/method.childReturnType.neon index adf2f2999aaf..cb796e2ba89f 100644 --- a/utils/phpstan-baseline/method.childReturnType.neon +++ b/utils/phpstan-baseline/method.childReturnType.neon @@ -1,4 +1,4 @@ -# total 29 errors +# total 28 errors parameters: ignoreErrors: @@ -93,22 +93,22 @@ parameters: path: ../../system/Database/SQLite3/PreparedQuery.php - - message: '#^Return type \(CodeIgniter\\HTTP\\DownloadResponse\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:sendBody\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\Response\)\) of method CodeIgniter\\HTTP\\Response\:\:sendBody\(\)$#' + message: '#^Return type \(CodeIgniter\\HTTP\\DownloadResponse\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:sendBody\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\ResponseInterface\)\) of method CodeIgniter\\HTTP\\ResponseInterface\:\:sendBody\(\)$#' count: 1 path: ../../system/HTTP/DownloadResponse.php - - message: '#^Return type \(CodeIgniter\\HTTP\\DownloadResponse\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:sendBody\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\ResponseInterface\)\) of method CodeIgniter\\HTTP\\ResponseInterface\:\:sendBody\(\)$#' + message: '#^Return type \(CodeIgniter\\HTTP\\DownloadResponse\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:sendBody\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\Response\)\) of method CodeIgniter\\HTTP\\Response\:\:sendBody\(\)$#' count: 1 path: ../../system/HTTP/DownloadResponse.php - - message: '#^Return type \(CodeIgniter\\HTTP\\ResponseInterface\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:setContentType\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\Response\)\) of method CodeIgniter\\HTTP\\Response\:\:setContentType\(\)$#' + message: '#^Return type \(CodeIgniter\\HTTP\\ResponseInterface\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:setContentType\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\ResponseInterface\)\) of method CodeIgniter\\HTTP\\ResponseInterface\:\:setContentType\(\)$#' count: 1 path: ../../system/HTTP/DownloadResponse.php - - message: '#^Return type \(CodeIgniter\\HTTP\\ResponseInterface\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:setContentType\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\ResponseInterface\)\) of method CodeIgniter\\HTTP\\ResponseInterface\:\:setContentType\(\)$#' + message: '#^Return type \(CodeIgniter\\HTTP\\ResponseInterface\) of method CodeIgniter\\HTTP\\DownloadResponse\:\:setContentType\(\) should be covariant with return type \(\$this\(CodeIgniter\\HTTP\\Response\)\) of method CodeIgniter\\HTTP\\Response\:\:setContentType\(\)$#' count: 1 path: ../../system/HTTP/DownloadResponse.php @@ -141,8 +141,3 @@ parameters: message: '#^Return type \(CodeIgniter\\Images\\Handlers\\ImageMagickHandler\) of method CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:_resize\(\) should be covariant with return type \(\$this\(CodeIgniter\\Images\\Handlers\\BaseHandler\)\) of method CodeIgniter\\Images\\Handlers\\BaseHandler\:\:_resize\(\)$#' count: 1 path: ../../system/Images/Handlers/ImageMagickHandler.php - - - - message: '#^Return type \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Model\:\:__call\(\) should be covariant with return type \(\$this\(CodeIgniter\\BaseModel\)\|null\) of method CodeIgniter\\BaseModel\:\:__call\(\)$#' - count: 1 - path: ../../system/Model.php diff --git a/utils/phpstan-baseline/method.notFound.neon b/utils/phpstan-baseline/method.notFound.neon index 8bc0b8ae54c0..1018f0fc8119 100644 --- a/utils/phpstan-baseline/method.notFound.neon +++ b/utils/phpstan-baseline/method.notFound.neon @@ -58,12 +58,12 @@ parameters: path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getFile\(\)\.$#' + message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getFileMultiple\(\)\.$#' count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getFileMultiple\(\)\.$#' + message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getFile\(\)\.$#' count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php @@ -73,13 +73,13 @@ parameters: path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getGet\(\)\.$#' - count: 2 + message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getGetPost\(\)\.$#' + count: 5 path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getGetPost\(\)\.$#' - count: 5 + message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getGet\(\)\.$#' + count: 2 path: ../../tests/system/HTTP/IncomingRequestTest.php - @@ -92,24 +92,19 @@ parameters: count: 9 path: ../../tests/system/HTTP/IncomingRequestTest.php - - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getPost\(\)\.$#' - count: 2 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getPostGet\(\)\.$#' count: 5 path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getVar\(\)\.$#' + message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getPost\(\)\.$#' count: 2 path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:is\(\)\.$#' - count: 5 + message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getVar\(\)\.$#' + count: 2 path: ../../tests/system/HTTP/IncomingRequestTest.php - @@ -127,6 +122,11 @@ parameters: count: 3 path: ../../tests/system/HTTP/IncomingRequestTest.php + - + message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:is\(\)\.$#' + count: 5 + path: ../../tests/system/HTTP/IncomingRequestTest.php + - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:negotiate\(\)\.$#' count: 5 diff --git a/utils/phpstan-baseline/missingType.callable.neon b/utils/phpstan-baseline/missingType.callable.neon index 9429a25441eb..5d0d90d58d56 100644 --- a/utils/phpstan-baseline/missingType.callable.neon +++ b/utils/phpstan-baseline/missingType.callable.neon @@ -3,22 +3,22 @@ parameters: ignoreErrors: - - message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method when\(\) parameter \#2 \$callback with no signature specified for callable\.$#' + message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method whenNot\(\) parameter \#2 \$callback with no signature specified for callable\.$#' count: 1 path: ../../system/Model.php - - message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method when\(\) parameter \#3 \$defaultCallback with no signature specified for callable\.$#' + message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method whenNot\(\) parameter \#3 \$defaultCallback with no signature specified for callable\.$#' count: 1 path: ../../system/Model.php - - message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method whenNot\(\) parameter \#2 \$callback with no signature specified for callable\.$#' + message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method when\(\) parameter \#2 \$callback with no signature specified for callable\.$#' count: 1 path: ../../system/Model.php - - message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method whenNot\(\) parameter \#3 \$defaultCallback with no signature specified for callable\.$#' + message: '#^Class CodeIgniter\\Model has PHPDoc tag @method for method when\(\) parameter \#3 \$defaultCallback with no signature specified for callable\.$#' count: 1 path: ../../system/Model.php diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 5f36d7f3b445..9d944d2ed357 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,182 +1,7 @@ -# total 1370 errors +# total 1320 errors parameters: ignoreErrors: - - - message: '#^Method CodeIgniter\\BaseModel\:\:__call\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:cleanValidationRules\(\) has parameter \$rules with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:cleanValidationRules\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:convertToReturnType\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doDelete\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doFind\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doFind\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doFindAll\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doFindColumn\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doFirst\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doInsertBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doProtectFields\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doProtectFieldsForInsert\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doUpdate\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:doUpdateBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:find\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:findAll\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:findColumn\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:first\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:getIdValue\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:getValidationMessages\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:getValidationRules\(\) has parameter \$options with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:getValidationRules\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:paginate\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:setAllowedFields\(\) has parameter \$allowedFields with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:setCreatedField\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:setUpdatedField\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:setValidationMessage\(\) has parameter \$fieldMessages with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:setValidationMessages\(\) has parameter \$validationMessages with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:setValidationRule\(\) has parameter \$fieldRules with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:transformDataToArray\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:trigger\(\) has parameter \$eventData with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:trigger\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - - - message: '#^Method CodeIgniter\\BaseModel\:\:update\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/BaseModel.php - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:isZeroOptions\(\) has parameter \$options with no value type specified in iterable type array\.$#' count: 1 @@ -187,11 +12,6 @@ parameters: count: 1 path: ../../system/CLI/CLI.php - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:prompt\(\) has parameter \$validation with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:promptByKey\(\) has parameter \$options with no value type specified in iterable type array\.$#' count: 1 @@ -217,6 +37,11 @@ parameters: count: 1 path: ../../system/CLI/CLI.php + - + message: '#^Method CodeIgniter\\CLI\\CLI\:\:prompt\(\) has parameter \$validation with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/CLI/CLI.php + - message: '#^Method CodeIgniter\\CLI\\CLI\:\:table\(\) has parameter \$tbody with no value type specified in iterable type array\.$#' count: 1 @@ -518,27 +343,27 @@ parameters: path: ../../system/Controller.php - - message: '#^Method CodeIgniter\\Controller\:\:validate\(\) has parameter \$messages with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Controller\:\:validateData\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Controller.php - - message: '#^Method CodeIgniter\\Controller\:\:validate\(\) has parameter \$rules with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Controller\:\:validateData\(\) has parameter \$messages with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Controller.php - - message: '#^Method CodeIgniter\\Controller\:\:validateData\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Controller\:\:validateData\(\) has parameter \$rules with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Controller.php - - message: '#^Method CodeIgniter\\Controller\:\:validateData\(\) has parameter \$messages with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Controller\:\:validate\(\) has parameter \$messages with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Controller.php - - message: '#^Method CodeIgniter\\Controller\:\:validateData\(\) has parameter \$rules with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Controller\:\:validate\(\) has parameter \$rules with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Controller.php @@ -582,11 +407,6 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php - - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:_whereIn\(\) has parameter \$values with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:batchObjectToArray\(\) has parameter \$object with no value type specified in iterable type array\.$#' count: 1 @@ -598,17 +418,17 @@ parameters: path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:delete\(\) has parameter \$where with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:deleteBatch\(\) has parameter \$constraints with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:deleteBatch\(\) has parameter \$constraints with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:deleteBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:deleteBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:delete\(\) has parameter \$where with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php @@ -662,11 +482,6 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php - - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:having\(\) has parameter \$key with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:havingIn\(\) has parameter \$values with no value type specified in iterable type array\.$#' count: 1 @@ -683,7 +498,7 @@ parameters: path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:insert\(\) has parameter \$set with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:having\(\) has parameter \$key with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php @@ -692,6 +507,11 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php + - + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:insert\(\) has parameter \$set with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Database/BaseBuilder.php + - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:like\(\) has parameter \$field with no value type specified in iterable type array\.$#' count: 1 @@ -722,11 +542,6 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php - - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orHaving\(\) has parameter \$key with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orHavingIn\(\) has parameter \$values with no value type specified in iterable type array\.$#' count: 1 @@ -743,22 +558,22 @@ parameters: path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orLike\(\) has parameter \$field with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orHaving\(\) has parameter \$key with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orNotHavingLike\(\) has parameter \$field with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orLike\(\) has parameter \$field with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orNotLike\(\) has parameter \$field with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orNotHavingLike\(\) has parameter \$field with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orWhere\(\) has parameter \$key with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orNotLike\(\) has parameter \$field with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php @@ -773,17 +588,17 @@ parameters: path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:replace\(\) has parameter \$set with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:orWhere\(\) has parameter \$key with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:resetRun\(\) has parameter \$qbResetItems with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:replace\(\) has parameter \$set with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:set\(\) has parameter \$key with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:resetRun\(\) has parameter \$qbResetItems with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php @@ -803,37 +618,37 @@ parameters: path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:trackAliases\(\) has parameter \$table with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:set\(\) has parameter \$key with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:update\(\) has parameter \$set with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:trackAliases\(\) has parameter \$table with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:update\(\) has parameter \$where with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:updateBatch\(\) has parameter \$constraints with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:updateBatch\(\) has parameter \$constraints with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:updateBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:updateBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:updateFields\(\) has parameter \$ignore with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:updateFields\(\) has parameter \$ignore with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:update\(\) has parameter \$set with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:upsert\(\) has parameter \$set with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:update\(\) has parameter \$where with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php @@ -843,7 +658,7 @@ parameters: path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:where\(\) has parameter \$key with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:upsert\(\) has parameter \$set with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseBuilder.php @@ -862,6 +677,11 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php + - + message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:where\(\) has parameter \$key with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Database/BaseBuilder.php + - message: '#^Property CodeIgniter\\Database\\BaseBuilder\:\:\$QBFrom type has no value type specified in iterable type array\.$#' count: 1 @@ -958,22 +778,22 @@ parameters: path: ../../system/Database/BaseConnection.php - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escape\(\) has parameter \$str with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escapeIdentifiers\(\) has parameter \$item with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseConnection.php - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escape\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escapeIdentifiers\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseConnection.php - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escapeIdentifiers\(\) has parameter \$item with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escape\(\) has parameter \$str with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseConnection.php - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escapeIdentifiers\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escape\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseConnection.php @@ -1098,22 +918,22 @@ parameters: path: ../../system/Database/BaseResult.php - - message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getResult\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getResultArray\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseResult.php - - message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getResultArray\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getResult\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseResult.php - - message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getRow\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getRowArray\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseResult.php - - message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getRowArray\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\BaseResult\:\:getRow\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/BaseResult.php @@ -1293,12 +1113,12 @@ parameters: path: ../../system/Database/Forge.php - - message: '#^Method CodeIgniter\\Database\\Forge\:\:_createTable\(\) has parameter \$attributes with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\Forge\:\:_createTableAttributes\(\) has parameter \$attributes with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/Forge.php - - message: '#^Method CodeIgniter\\Database\\Forge\:\:_createTableAttributes\(\) has parameter \$attributes with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\Forge\:\:_createTable\(\) has parameter \$attributes with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/Forge.php @@ -1713,27 +1533,27 @@ parameters: path: ../../system/Database/ResultInterface.php - - message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getResult\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getResultArray\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/ResultInterface.php - - message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getResultArray\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getResultObject\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/ResultInterface.php - - message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getResultObject\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getResult\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/ResultInterface.php - - message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getRow\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getRowArray\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/ResultInterface.php - - message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getRowArray\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Database\\ResultInterface\:\:getRow\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/ResultInterface.php @@ -2068,22 +1888,22 @@ parameters: path: ../../system/Debug/Toolbar.php - - message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimeline\(\) has parameter \$collectors with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimelineRecursive\(\) has parameter \$rows with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Debug/Toolbar.php - - message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimeline\(\) has parameter \$styles with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimelineRecursive\(\) has parameter \$styles with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Debug/Toolbar.php - - message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimelineRecursive\(\) has parameter \$rows with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimeline\(\) has parameter \$collectors with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Debug/Toolbar.php - - message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimelineRecursive\(\) has parameter \$styles with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Debug\\Toolbar\:\:renderTimeline\(\) has parameter \$styles with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Debug/Toolbar.php @@ -2613,32 +2433,32 @@ parameters: path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGetPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGetPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGet\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGetPost\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGetPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGetPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGetPost\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getGet\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php @@ -2648,32 +2468,32 @@ parameters: path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPostGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPostGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPost\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPostGet\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPostGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPostGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPostGet\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getPost\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/CLIRequest.php @@ -3073,32 +2893,32 @@ parameters: path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGetPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGetPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGetPost\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGetPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGetPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGetPost\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php @@ -3128,52 +2948,52 @@ parameters: path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPostGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPostGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPostGet\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPostGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPostGet\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPostGet\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInput\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) has parameter \$flags with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) has parameter \$index with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInput\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/IncomingRequest.php @@ -3257,11 +3077,6 @@ parameters: count: 1 path: ../../system/HTTP/Negotiate.php - - - message: '#^Method CodeIgniter\\HTTP\\Negotiate\:\:match\(\) has parameter \$acceptable with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/Negotiate.php - - message: '#^Method CodeIgniter\\HTTP\\Negotiate\:\:matchLocales\(\) has parameter \$acceptable with no value type specified in iterable type array\.$#' count: 1 @@ -3292,6 +3107,11 @@ parameters: count: 1 path: ../../system/HTTP/Negotiate.php + - + message: '#^Method CodeIgniter\\HTTP\\Negotiate\:\:match\(\) has parameter \$acceptable with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/HTTP/Negotiate.php + - message: '#^Method CodeIgniter\\HTTP\\Negotiate\:\:media\(\) has parameter \$supported with no value type specified in iterable type array\.$#' count: 1 @@ -4002,76 +3822,6 @@ parameters: count: 1 path: ../../system/Images/Image.php - - - message: '#^Method CodeIgniter\\Model\:\:__call\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:__call\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:doDelete\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:doFind\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:doInsertBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:doProtectFieldsForInsert\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:doUpdate\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:doUpdateBatch\(\) has parameter \$set with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:getIdValue\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:set\(\) has parameter \$key with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:shouldUpdate\(\) has parameter \$row with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Method CodeIgniter\\Model\:\:update\(\) has parameter \$id with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - - - message: '#^Property CodeIgniter\\Model\:\:\$escape type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Model.php - - message: '#^Method CodeIgniter\\Modules\\Modules\:\:__set_state\(\) has parameter \$array with no value type specified in iterable type array\.$#' count: 1 @@ -4183,17 +3933,17 @@ parameters: path: ../../system/Router/AutoRouterInterface.php - - message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:add\(\) has parameter \$options with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:addPlaceholder\(\) has parameter \$placeholder with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollection.php - - message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:add\(\) has parameter \$to with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:add\(\) has parameter \$options with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollection.php - - message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:addPlaceholder\(\) has parameter \$placeholder with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:add\(\) has parameter \$to with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollection.php @@ -4238,17 +3988,17 @@ parameters: path: ../../system/Router/RouteCollection.php - - message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:get\(\) has parameter \$options with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:getRoutes\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollection.php - - message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:get\(\) has parameter \$to with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:get\(\) has parameter \$options with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollection.php - - message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:getRoutes\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:get\(\) has parameter \$to with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollection.php @@ -4378,17 +4128,17 @@ parameters: path: ../../system/Router/RouteCollection.php - - message: '#^Method CodeIgniter\\Router\\RouteCollectionInterface\:\:add\(\) has parameter \$options with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollectionInterface\:\:addPlaceholder\(\) has parameter \$placeholder with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollectionInterface.php - - message: '#^Method CodeIgniter\\Router\\RouteCollectionInterface\:\:add\(\) has parameter \$to with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollectionInterface\:\:add\(\) has parameter \$options with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollectionInterface.php - - message: '#^Method CodeIgniter\\Router\\RouteCollectionInterface\:\:addPlaceholder\(\) has parameter \$placeholder with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\RouteCollectionInterface\:\:add\(\) has parameter \$to with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/RouteCollectionInterface.php @@ -4398,12 +4148,12 @@ parameters: path: ../../system/Router/RouteCollectionInterface.php - - message: '#^Method CodeIgniter\\Router\\Router\:\:getMatchedRoute\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\Router\:\:getMatchedRouteOptions\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/Router.php - - message: '#^Method CodeIgniter\\Router\\Router\:\:getMatchedRouteOptions\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Router\\Router\:\:getMatchedRoute\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Router/Router.php @@ -4518,12 +4268,12 @@ parameters: path: ../../system/Test/Fabricator.php - - message: '#^Method CodeIgniter\\Test\\Fabricator\:\:create\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Test\\Fabricator\:\:createMock\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Test/Fabricator.php - - message: '#^Method CodeIgniter\\Test\\Fabricator\:\:createMock\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Test\\Fabricator\:\:create\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Test/Fabricator.php @@ -4538,12 +4288,12 @@ parameters: path: ../../system/Test/Fabricator.php - - message: '#^Method CodeIgniter\\Test\\Fabricator\:\:make\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Test\\Fabricator\:\:makeArray\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Test/Fabricator.php - - message: '#^Method CodeIgniter\\Test\\Fabricator\:\:makeArray\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Test\\Fabricator\:\:make\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Test/Fabricator.php @@ -5668,12 +5418,12 @@ parameters: path: ../../tests/system/HTTP/ResponseCookieTest.php - - message: '#^Method CodeIgniter\\HTTP\\ResponseTest\:\:provideRedirect\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\HTTP\\ResponseTest\:\:provideRedirectWithIIS\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/HTTP/ResponseTest.php - - message: '#^Method CodeIgniter\\HTTP\\ResponseTest\:\:provideRedirectWithIIS\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\HTTP\\ResponseTest\:\:provideRedirect\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/HTTP/ResponseTest.php @@ -5912,11 +5662,6 @@ parameters: count: 1 path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideAnchor\(\) return type has no value type specified in iterable type iterable\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php - - message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideAnchorExamples\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 @@ -5937,6 +5682,11 @@ parameters: count: 1 path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php + - + message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideAnchor\(\) return type has no value type specified in iterable type iterable\.$#' + count: 1 + path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php + - message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideAutoLinkEmail\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 @@ -5973,12 +5723,12 @@ parameters: path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php - - message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideUrlTo\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideUrlToThrowsOnEmptyOrMissingRoute\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php - - message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideUrlToThrowsOnEmptyOrMissingRoute\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Helpers\\URLHelper\\MiscUrlTest\:\:provideUrlTo\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -6333,22 +6083,22 @@ parameters: path: ../../tests/system/Validation/FormatRulesTest.php - - message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlpha\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlphaDash\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/FormatRulesTest.php - - message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlphaDash\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlphaNumericPunct\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/FormatRulesTest.php - - message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlphaNumericPunct\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlphaSpace\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/FormatRulesTest.php - - message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlphaSpace\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideAlpha\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/FormatRulesTest.php @@ -6383,12 +6133,12 @@ parameters: path: ../../tests/system/Validation/FormatRulesTest.php - - message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideNatural\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideNaturalNoZero\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/FormatRulesTest.php - - message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideNaturalNoZero\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\FormatRulesTest\:\:provideNatural\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/FormatRulesTest.php @@ -6453,12 +6203,12 @@ parameters: path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideGreaterThan\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideGreaterThanEqual\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideGreaterThanEqual\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideGreaterThan\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php @@ -6473,22 +6223,22 @@ parameters: path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideLessThan\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideLessThanEqual\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideLessThanEqual\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideLessThan\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideMatches\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideMatchesNestedCases\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideMatchesNestedCases\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideMatches\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php @@ -6503,47 +6253,47 @@ parameters: path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequired\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithAndOtherRuleWithValueZero\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWith\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithAndOtherRules\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithAndOtherRuleWithValueZero\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWith\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithAndOtherRules\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithoutMultipleWithoutFields\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithout\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithoutMultiple\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithoutMultiple\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithout\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequiredWithoutMultipleWithoutFields\(\) return type has no value type specified in iterable type iterable\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:provideRequired\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testDiffers\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testDiffersNested\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testDiffersNested\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testDiffers\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php @@ -6573,12 +6323,12 @@ parameters: path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testMatches\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testMatchesNested\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testMatchesNested\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testMatches\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php @@ -6593,22 +6343,22 @@ parameters: path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequired\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequiredWithAndOtherRuleWithValueZero\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequiredWithAndOtherRuleWithValueZero\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequiredWithAndOtherRules\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequiredWithAndOtherRules\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequiredWithoutMultipleWithoutFields\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php - - message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequiredWithoutMultipleWithoutFields\(\) has parameter \$data with no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\Validation\\RulesTest\:\:testRequired\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/Validation/RulesTest.php From 3d993c5ee19b99de0de14c7d1f022d935307af0c Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 14 Dec 2025 18:00:58 +0100 Subject: [PATCH 46/84] feat(entity): deep change tracking for objects and arrays (#9779) * feat(entity): deep change tracking for objects and arrays * use JSON_UNESCAPED_UNICODE and JSON_UNESCAPED_SLASHES with json_encode * handle DateTimeInterface objects * handle native SPL iterators * update user guide * handle value objects with __toString() --- system/Entity/Entity.php | 111 ++- tests/system/Entity/EntityTest.php | 718 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 26 + user_guide_src/source/models/entities.rst | 21 + 4 files changed, 873 insertions(+), 3 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 0cbcdef00904..59d18e9fe8a5 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Entity; +use BackedEnum; use CodeIgniter\DataCaster\DataCaster; use CodeIgniter\Entity\Cast\ArrayCast; use CodeIgniter\Entity\Cast\BooleanCast; @@ -30,9 +31,12 @@ use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\I18n\Time; use DateTime; +use DateTimeInterface; use Exception; use JsonSerializable; use ReturnTypeWillChange; +use Traversable; +use UnitEnum; /** * Entity encapsulation, for use with CodeIgniter\Model @@ -131,6 +135,11 @@ class Entity implements JsonSerializable */ private bool $_cast = true; + /** + * Indicates whether all attributes are scalars (for optimization) + */ + private bool $_onlyScalars = true; + /** * Allows filling in Entity parameters during construction. */ @@ -263,11 +272,24 @@ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): /** * Ensures our "original" values match the current values. * + * Objects and arrays are normalized and JSON-encoded for reliable change detection, + * while scalars are stored as-is for performance. + * * @return $this */ public function syncOriginal() { - $this->original = $this->attributes; + $this->original = []; + $this->_onlyScalars = true; + + foreach ($this->attributes as $key => $value) { + if (is_object($value) || is_array($value)) { + $this->original[$key] = json_encode($this->normalizeValue($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $this->_onlyScalars = false; + } else { + $this->original[$key] = $value; + } + } return $this; } @@ -283,7 +305,17 @@ public function hasChanged(?string $key = null): bool { // If no parameter was given then check all attributes if ($key === null) { - return $this->original !== $this->attributes; + if ($this->_onlyScalars) { + return $this->original !== $this->attributes; + } + + foreach (array_keys($this->attributes) as $attributeKey) { + if ($this->hasChanged($attributeKey)) { + return true; + } + } + + return false; } $dbColumn = $this->mapProperty($key); @@ -298,7 +330,80 @@ public function hasChanged(?string $key = null): bool return true; } - return $this->original[$dbColumn] !== $this->attributes[$dbColumn]; + // It was removed + if (array_key_exists($dbColumn, $this->original) && ! array_key_exists($dbColumn, $this->attributes)) { + return true; + } + + $originalValue = $this->original[$dbColumn]; + $currentValue = $this->attributes[$dbColumn]; + + // If original is a string, it was JSON-encoded (object or array) + if (is_string($originalValue) && (is_object($currentValue) || is_array($currentValue))) { + return $originalValue !== json_encode($this->normalizeValue($currentValue), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + // For scalars, use direct comparison + return $originalValue !== $currentValue; + } + + /** + * Recursively normalize a value for comparison. + * Converts objects and arrays to a JSON-encodable format. + */ + private function normalizeValue(mixed $data): mixed + { + if (is_array($data)) { + $normalized = []; + + foreach ($data as $key => $value) { + $normalized[$key] = $this->normalizeValue($value); + } + + return $normalized; + } + + if (is_object($data)) { + // Check for Entity instance (use raw values, recursive) + if ($data instanceof Entity) { + $objectData = $data->toRawArray(false, true); + } elseif ($data instanceof JsonSerializable) { + $objectData = $data->jsonSerialize(); + } elseif (method_exists($data, 'toArray')) { + $objectData = $data->toArray(); + } elseif ($data instanceof Traversable) { + $objectData = iterator_to_array($data); + } elseif ($data instanceof DateTimeInterface) { + return [ + '__class' => $data::class, + '__datetime' => $data->format(DATE_RFC3339_EXTENDED), + ]; + } elseif ($data instanceof UnitEnum) { + return [ + '__class' => $data::class, + '__enum' => $data instanceof BackedEnum ? $data->value : $data->name, + ]; + } else { + $objectData = get_object_vars($data); + + // Fallback for value objects with __toString() + // when properties are not accessible + if ($objectData === [] && method_exists($data, '__toString')) { + return [ + '__class' => $data::class, + '__string' => (string) $data, + ]; + } + } + + return [ + '__class' => $data::class, + '__data' => $this->normalizeValue($objectData), + ]; + } + + // Return scalars and null as-is + return $data; } /** diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 2551cd18ffad..76fe1589ec08 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Entity; +use ArrayIterator; +use ArrayObject; use Closure; use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\HTTP\URI; @@ -21,9 +23,12 @@ use CodeIgniter\Test\ReflectionHelper; use DateTime; use DateTimeInterface; +use DateTimeZone; +use JsonSerializable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use ReflectionException; +use stdClass; use Tests\Support\Entity\Cast\CastBase64; use Tests\Support\Entity\Cast\CastPassParameters; use Tests\Support\Entity\Cast\NotExtendsBaseCast; @@ -1564,4 +1569,717 @@ private function getCustomCastEntity(): object ]; }; } + + public function testHasChangedWithScalarsOnlyUsesOptimization(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => 'test', + 'flag' => true, + ]; + }; + + // Sync original to set $_onlyScalars = true + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged()); + + $entity->id = 2; + + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedWithObjectsDoesNotUseOptimization(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'data' => null, + ]; + }; + + $entity->data = new stdClass(); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged()); + + $newObj = new stdClass(); + $newObj->test = 'value'; + $entity->data = $newObj; + + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedDetectsArrayChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'items' => ['a', 'b', 'c'], + ]; + }; + + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('items')); + + $entity->items = ['a', 'b', 'd']; + + $this->assertTrue($entity->hasChanged('items')); + } + + public function testHasChangedDetectsNestedArrayChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'data' => [ + 'level1' => [ + 'level2' => 'value', + ], + ], + ]; + }; + + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('data')); + + $entity->data = [ + 'level1' => [ + 'level2' => 'different', + ], + ]; + + $this->assertTrue($entity->hasChanged('data')); + } + + public function testHasChangedDetectsObjectPropertyChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'obj' => null, + ]; + }; + + $obj = new stdClass(); + $obj->prop = 'original'; + $entity->obj = $obj; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('obj')); + + $newObj = new stdClass(); + $newObj->prop = 'modified'; + $entity->obj = $newObj; + + $this->assertTrue($entity->hasChanged('obj')); + } + + public function testHasChangedWithNestedEntity(): void + { + $innerEntity = new SomeEntity(['foo' => 'bar']); + $outerEntity = new class () extends Entity { + protected $attributes = [ + 'nested' => null, + ]; + }; + $outerEntity->nested = $innerEntity; + $outerEntity->syncOriginal(); + + $this->assertFalse($outerEntity->hasChanged('nested')); + + $newInner = new SomeEntity(['foo' => 'baz']); + $outerEntity->nested = $newInner; + + $this->assertTrue($outerEntity->hasChanged('nested')); + } + + public function testHasChangedWithJsonSerializable(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'data' => null, + ]; + }; + + $obj1 = new class () implements JsonSerializable { + public function jsonSerialize(): mixed + { + return ['value' => 'original']; + } + }; + + $entity->data = $obj1; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('data')); + + $obj2 = new class () implements JsonSerializable { + public function jsonSerialize(): mixed + { + return ['value' => 'modified']; + } + }; + + $entity->data = $obj2; + + $this->assertTrue($entity->hasChanged('data')); + } + + public function testHasChangedDoesNotDetectUnchangedObject(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'obj' => null, + ]; + }; + + $obj = new stdClass(); + $obj->prop = 'value'; + $entity->obj = $obj; + $entity->syncOriginal(); + + $sameObj = new stdClass(); + $sameObj->prop = 'value'; + $entity->obj = $sameObj; + + $this->assertFalse($entity->hasChanged('obj')); + } + + public function testSyncOriginalWithMixedTypes(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'scalar' => 'text', + 'number' => 42, + 'array' => [1, 2, 3], + 'object' => null, + 'null' => null, + 'boolean' => true, + ]; + }; + + $obj = new stdClass(); + $obj->prop = 'value'; + $entity->object = $obj; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + + // Scalars should be stored as-is + $this->assertSame('text', $original['scalar']); + $this->assertSame(42, $original['number']); + $this->assertNull($original['null']); + $this->assertTrue($original['boolean']); + + // Objects and arrays should be JSON-encoded + $this->assertIsString($original['array']); + $this->assertIsString($original['object']); + $this->assertSame(json_encode([1, 2, 3]), $original['array']); + } + + public function testSyncOriginalSetsHasOnlyScalarsFalseWithArrays(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'items' => ['a', 'b'], + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + $this->assertIsString($original['items']); + $this->assertSame(json_encode(['a', 'b']), $original['items']); + } + + public function testSyncOriginalSetsHasOnlyScalarsTrueWithOnlyScalars(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => 'test', + 'active' => true, + 'price' => 99.99, + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + $this->assertSame(1, $original['id']); + $this->assertSame('test', $original['name']); + $this->assertTrue($original['active']); + $this->assertEqualsWithDelta(99.99, $original['price'], PHP_FLOAT_EPSILON); + } + + public function testHasChangedWithObjectToArray(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'data' => null, + ]; + }; + + $entity->data = new stdClass(); + $entity->syncOriginal(); + + $entity->data = []; + + $this->assertTrue($entity->hasChanged('data')); + } + + public function testHasChangedWithRemovedKey(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'foo' => 'bar', + ]; + }; + + $entity->syncOriginal(); + + unset($entity->foo); + + $this->assertTrue($entity->hasChanged('foo')); + } + + public function testNormalizeValueWithEntityToArray(): void + { + $innerEntity = new SomeEntity(['foo' => 'bar', 'bar' => 'baz']); + $entity = new class () extends Entity { + protected $attributes = [ + 'nested' => null, + ]; + }; + + $entity->nested = $innerEntity; + $entity->syncOriginal(); + + // Change inner entity property + $innerEntity2 = new SomeEntity(['foo' => 'changed', 'bar' => 'baz']); + $entity->nested = $innerEntity2; + + $this->assertTrue($entity->hasChanged('nested')); + } + + public function testHasChangedWithComplexNestedStructure(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'complex' => null, + ]; + }; + + $complex = [ + 'level1' => [ + 'level2' => [ + 'value' => 'original', + ], + ], + ]; + + $entity->complex = $complex; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('complex')); + + // Deep change + $complex['level1']['level2']['value'] = 'modified'; + $entity->complex = $complex; + + $this->assertTrue($entity->hasChanged('complex')); + } + + public function testHasChangedWithObjectContainingArray(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'obj' => null, + ]; + }; + + $obj = new stdClass(); + $obj->items = ['a', 'b', 'c']; + $entity->obj = $obj; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('obj')); + + // Change array inside object + $newObj = new stdClass(); + $newObj->items = ['a', 'b', 'd']; + $entity->obj = $newObj; + + $this->assertTrue($entity->hasChanged('obj')); + } + + public function testSyncOriginalAfterMultipleChanges(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'value' => 'original', + ]; + }; + + $entity->syncOriginal(); + $this->assertFalse($entity->hasChanged()); + + $entity->value = 'changed1'; + $this->assertTrue($entity->hasChanged()); + + $entity->syncOriginal(); + $this->assertFalse($entity->hasChanged()); + + $entity->value = 'changed2'; + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedWithArrayOfObjects(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'items' => null, + ]; + }; + + $obj1 = new stdClass(); + $obj1->id = 1; + $obj1->name = 'First'; + + $obj2 = new stdClass(); + $obj2->id = 2; + $obj2->name = 'Second'; + + $entity->items = [$obj1, $obj2]; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('items')); + + $obj3 = new stdClass(); + $obj3->id = 1; + $obj3->name = 'Modified'; + + $entity->items = [$obj3, $obj2]; + + $this->assertTrue($entity->hasChanged('items')); + } + + public function testHasChangedWithEmptyArrays(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'tags' => [], + ]; + }; + + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('tags')); + + $entity->tags = ['tag1']; + + $this->assertTrue($entity->hasChanged('tags')); + + $entity->syncOriginal(); + $entity->tags = []; + + $this->assertTrue($entity->hasChanged('tags')); + } + + public function testHasChangedWithObjectWithToArrayMethod(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'custom' => null, + ]; + }; + + // Create object with toArray() method + $obj1 = new class () { + /** + * @return array + */ + public function toArray(): array + { + return ['key' => 'value1']; + } + }; + + $entity->custom = $obj1; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('custom')); + + // Create different object with same class but different toArray() result + $obj2 = new class () { + /** + * @return array + */ + public function toArray(): array + { + return ['key' => 'value2']; + } + }; + + $entity->custom = $obj2; + + $this->assertTrue($entity->hasChanged('custom')); + } + + public function testHasChangedScalarOptimizationWithNullValues(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => null, + 'email' => null, + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + $this->assertSame(1, $original['id']); + $this->assertNull($original['name']); + $this->assertNull($original['email']); + + $this->assertFalse($entity->hasChanged()); + + // Change null to string + $entity->name = 'John'; + + $this->assertTrue($entity->hasChanged()); + } + + public function testHasChangedDetectsNewPropertyAddition(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'existing' => 'value', + ]; + }; + + $entity->syncOriginal(); + + // Add new property + $entity->newProp = 'new value'; + + $this->assertTrue($entity->hasChanged()); + $this->assertTrue($entity->hasChanged('newProp')); + } + + public function testHasChangedWithBackedEnumString(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'status' => null, + ]; + }; + + $entity->status = StatusEnum::ACTIVE; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('status')); + + $entity->status = StatusEnum::PENDING; + + $this->assertTrue($entity->hasChanged('status')); + } + + public function testHasChangedWithBackedEnumInt(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'role' => null, + ]; + }; + + $entity->role = RoleEnum::USER; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('role')); + + $entity->role = RoleEnum::ADMIN; + + $this->assertTrue($entity->hasChanged('role')); + } + + public function testHasChangedWithUnitEnum(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'color' => null, + ]; + }; + + $entity->color = ColorEnum::RED; + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('color')); + + $entity->color = ColorEnum::BLUE; + + $this->assertTrue($entity->hasChanged('color')); + } + + public function testHasChangedDoesNotDetectSameEnum(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'status' => null, + ]; + }; + + $entity->status = StatusEnum::ACTIVE; + $entity->syncOriginal(); + + $entity->status = StatusEnum::ACTIVE; + + $this->assertFalse($entity->hasChanged('status')); + } + + public function testSyncOriginalWithEnumValues(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'status' => StatusEnum::PENDING, + 'role' => RoleEnum::USER, + 'color' => ColorEnum::GREEN, + ]; + }; + + $entity->syncOriginal(); + + $original = $this->getPrivateProperty($entity, 'original'); + + // Enums should be JSON-encoded as objects + $this->assertIsString($original['status']); + $this->assertIsString($original['role']); + $this->assertIsString($original['color']); + + $statusData = json_decode($original['status'], true); + $this->assertSame(StatusEnum::class, $statusData['__class']); + $this->assertSame('pending', $statusData['__enum']); + + $roleData = json_decode($original['role'], true); + $this->assertSame(RoleEnum::class, $roleData['__class']); + $this->assertSame(1, $roleData['__enum']); + + $colorData = json_decode($original['color'], true); + $this->assertSame(ColorEnum::class, $colorData['__class']); + $this->assertSame('GREEN', $colorData['__enum']); + } + + public function testHasChangedWithDateTimeInterface(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'created_at' => null, + ]; + }; + + // Test with Time object + $entity->created_at = Time::parse('2024-01-01 12:00:00', 'UTC'); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('created_at')); + + $entity->created_at = Time::parse('2024-12-31 23:59:59', 'UTC'); + $this->assertTrue($entity->hasChanged('created_at')); + + $entity->syncOriginal(); + $entity->created_at = Time::parse('2024-12-31 23:59:59', 'UTC'); + $this->assertFalse($entity->hasChanged('created_at')); + + // Test timezone difference detection + $entity->created_at = new DateTime('2024-01-01 12:00:00', new DateTimeZone('UTC')); + $entity->syncOriginal(); + $entity->created_at = new DateTime('2024-01-01 12:00:00', new DateTimeZone('America/New_York')); + $this->assertTrue($entity->hasChanged('created_at')); + } + + public function testHasChangedWithTraversable(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'items' => null, + ]; + }; + + // Test with ArrayObject + $entity->items = new ArrayObject(['a', 'b', 'c']); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('items')); + + $entity->items = new ArrayObject(['a', 'b', 'd']); + $this->assertTrue($entity->hasChanged('items')); + + $entity->syncOriginal(); + $entity->items = new ArrayObject(['a', 'b', 'd']); + $this->assertFalse($entity->hasChanged('items')); + + // Test with ArrayIterator + $entity->items = new ArrayIterator(['x', 'y', 'z']); + $entity->syncOriginal(); + $entity->items = new ArrayIterator(['x', 'y', 'modified']); + $this->assertTrue($entity->hasChanged('items')); + + // Test with nested objects inside collection (verifies recursive normalization) + $obj1 = new stdClass(); + $obj1->name = 'first'; + + $obj2 = new stdClass(); + $obj2->name = 'second'; + + $entity->items = new ArrayObject([$obj1, $obj2]); + $entity->syncOriginal(); + + $obj3 = new stdClass(); + $obj3->name = 'modified'; + + $entity->items = new ArrayObject([$obj3, $obj2]); + $this->assertTrue($entity->hasChanged('items')); + } + + public function testHasChangedWithValueObjectsUsingToString(): void + { + // Define a value object class + $emailClass = new class () { + public static function create(string $email): object + { + return new class ($email) { + public function __construct(private readonly string $email) + { + } + + public function __toString(): string + { + return $this->email; + } + }; + } + }; + + $entity = new class () extends Entity { + protected $attributes = [ + 'email' => null, + ]; + }; + + $entity->email = $emailClass::create('old@example.com'); + $entity->syncOriginal(); + + $this->assertFalse($entity->hasChanged('email')); + + $entity->email = $emailClass::create('new@example.com'); + $this->assertTrue($entity->hasChanged('email')); + + $entity->syncOriginal(); + $entity->email = $emailClass::create('new@example.com'); + $this->assertFalse($entity->hasChanged('email')); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index bbc09fc8d60f..70b27b15a600 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -40,6 +40,32 @@ The ``insertBatch()`` and ``updateBatch()`` methods now honor model settings lik ``updateOnlyChanged`` and ``allowEmptyInserts``. This change ensures consistent handling across all insert/update operations. +Entity +------ + +The ``Entity::hasChanged()`` and ``Entity::syncOriginal()`` methods now perform deep comparison +for objects and arrays instead of shallow comparison. This means: + +- **Objects and arrays** are now JSON-encoded and normalized for comparison, detecting changes + in nested structures, object properties, and array elements. +- **Enums** (both ``BackedEnum`` and ``UnitEnum``) are properly tracked by their backing value + or case name. +- **DateTime objects** (``DateTimeInterface``) are compared using their ISO 8601 representation + including timezone information. +- **Collections** (``Traversable``) such as ``ArrayObject`` and ``ArrayIterator`` are converted + to arrays for comparison. +- **Value objects** with ``__toString()`` method are compared by their string representation when + properties are not accessible (fallback for objects with private properties). +- **Nested entities** (using ``toRawArray()``), ``JsonSerializable`` objects, and objects with + ``toArray()`` methods are recursively normalized for accurate change detection. +- **Scalar values** (strings, integers, floats, booleans, null) continue to use direct comparison + with an optimization when all entity attributes are scalars. + +Previously, changing an object property or an array containing objects would not be detected +as a change because only reference comparison was performed. Now, any modification to the internal +state of objects or arrays will be properly detected. If you relied on the old shallow comparison +behavior, you will need to update your code accordingly. + Interface Changes ================= diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 9d45d22a3a7a..62daf63e4f0c 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -378,3 +378,24 @@ attribute to check: Or to check the whole entity for changed values omit the parameter: .. literalinclude:: entities/023.php + +Deep Change Tracking +==================== + +.. versionadded:: 4.7.0 + +The Entity class performs **deep comparison** for objects and arrays to accurately detect changes in their internal state. + +Scalar Values +------------- + +For scalar values (strings, integers, floats, booleans, null), the Entity uses direct comparison. When all attributes +in an Entity are scalars, an optimized comparison is used for better performance. + +Objects and Arrays +------------------ + +For objects and arrays, the Entity JSON-encodes and normalizes the values for comparison. This means that modifications +to nested structures, object properties, array elements, nested entities (using ``toRawArray()``), enums (``BackedEnum`` +and ``UnitEnum``), datetime objects (``DateTimeInterface``), collections (``Traversable``), value objects with +``__toString()``, and objects implementing ``JsonSerializable`` or ``toArray()`` will be properly detected. From 53a4b416597710c4372d48cdcf854ffb724489d9 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Thu, 18 Dec 2025 09:01:35 +0100 Subject: [PATCH 47/84] fix: signal trait (#9846) --- system/CLI/SignalTrait.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php index 92eb2621df32..0490e15d9d55 100644 --- a/system/CLI/SignalTrait.php +++ b/system/CLI/SignalTrait.php @@ -95,13 +95,17 @@ protected function isPosixAvailable(): bool * @param array $methodMap Optional signal-to-method mapping */ protected function registerSignals( - array $signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT], + array $signals = [], array $methodMap = [], ): void { if (! $this->isPcntlAvailable()) { return; } + if ($signals === []) { + $signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT]; + } + if (! $this->isPosixAvailable() && (in_array(SIGTSTP, $signals, true) || in_array(SIGCONT, $signals, true))) { CLI::write(lang('CLI.signals.noPosixExtension'), 'yellow'); $signals = array_diff($signals, [SIGTSTP, SIGCONT]); From 59cbed59ed0a8661a61739d3bcb0f12951789716 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:48:50 +0700 Subject: [PATCH 48/84] refactor: remove `ignore-platform-req` (#9847) * refactor: remove `ignore-platform-req` * Remove ignore-platform-req=php from composer update * Remove PHP version check from composer.json Removed a PHP version check from post-autoload-dump command. --- .github/workflows/test-phpunit.yml | 14 +++----------- composer.json | 3 +-- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index b9a8666cbcb4..38f432c4b796 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -59,9 +59,7 @@ jobs: - '8.2' - '8.3' - '8.4' - include: - - php-version: '8.5' - composer-option: '--ignore-platform-req=php' + - '8.5' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: @@ -101,8 +99,6 @@ jobs: - php-version: '8.2' db-platform: MySQLi mysql-version: '5.7' - - php-version: '8.5' - composer-option: '--ignore-platform-req=php' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: @@ -130,9 +126,7 @@ jobs: - '8.2' - '8.3' - '8.4' - include: - - php-version: '8.5' - composer-option: '--ignore-platform-req=php' + - '8.5' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: @@ -158,9 +152,7 @@ jobs: - '8.2' - '8.3' - '8.4' - include: - - php-version: '8.5' - composer-option: '--ignore-platform-req=php' + - '8.5' uses: ./.github/workflows/reusable-phpunit-test.yml # @TODO Extract to codeigniter4/.github repo with: diff --git a/composer.json b/composer.json index 2c71f8e640e9..22a86d7664a9 100644 --- a/composer.json +++ b/composer.json @@ -91,8 +91,7 @@ "CodeIgniter\\ComposerScripts::postUpdate" ], "post-autoload-dump": [ - "@php -r \"if (PHP_VERSION_ID >= 80500) { echo '@todo Remove \"--ignore-platform-req=php\" once deps catch up.', PHP_EOL; }\"", - "@composer update --ansi --working-dir=utils --ignore-platform-req=php" + "@composer update --ansi --working-dir=utils" ], "analyze": [ "Composer\\Config::disableProcessTimeout", From 08bc0f835254fa13949280861810967fd42b314e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sat, 20 Dec 2025 16:10:52 +0100 Subject: [PATCH 49/84] fix: `null` as an array offset is deprecated, use an empty string instead (#9849) * fix: null as an array offset is deprecated, use an empty string instead Co-authored-by: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> * update tests signature --------- Co-authored-by: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> --- system/Autoloader/FileLocatorCached.php | 10 ++++++---- tests/system/Validation/RulesTest.php | 12 ++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/system/Autoloader/FileLocatorCached.php b/system/Autoloader/FileLocatorCached.php index 0aa267b2a84b..3e31a33d9e84 100644 --- a/system/Autoloader/FileLocatorCached.php +++ b/system/Autoloader/FileLocatorCached.php @@ -163,14 +163,16 @@ public function listNamespaceFiles(string $prefix, string $path): array public function locateFile(string $file, ?string $folder = null, string $ext = 'php'): false|string { - if (isset($this->cache['locateFile'][$file][$folder][$ext])) { - return $this->cache['locateFile'][$file][$folder][$ext]; + $folderKey = $folder ?? ''; + + if (isset($this->cache['locateFile'][$file][$folderKey][$ext])) { + return $this->cache['locateFile'][$file][$folderKey][$ext]; } $files = $this->locator->locateFile($file, $folder, $ext); - $this->cache['locateFile'][$file][$folder][$ext] = $files; - $this->cacheUpdated = true; + $this->cache['locateFile'][$file][$folderKey][$ext] = $files; + $this->cacheUpdated = true; return $files; } diff --git a/tests/system/Validation/RulesTest.php b/tests/system/Validation/RulesTest.php index 8bb6cab3ce77..37f7eebdfaa1 100644 --- a/tests/system/Validation/RulesTest.php +++ b/tests/system/Validation/RulesTest.php @@ -669,7 +669,7 @@ public static function provideInList(): iterable } #[DataProvider('provideRequiredWith')] - public function testRequiredWith(?string $field, ?string $check, bool $expected): void + public function testRequiredWith(string $field, ?string $check, bool $expected): void { $data = [ 'foo' => 'bar', @@ -693,8 +693,8 @@ public static function provideRequiredWith(): iterable ['nope', 'bar', false], ['foo', 'bar', true], ['nope', 'baz', true], - [null, null, true], - [null, 'foo', false], + ['', null, true], + ['', 'foo', false], ['foo', null, true], [ 'array.emptyField1', @@ -779,7 +779,7 @@ public static function provideRequiredWithAndOtherRuleWithValueZero(): iterable } #[DataProvider('provideRequiredWithout')] - public function testRequiredWithout(?string $field, ?string $check, bool $expected): void + public function testRequiredWithout(string $field, ?string $check, bool $expected): void { $data = [ 'foo' => 'bar', @@ -802,8 +802,8 @@ public static function provideRequiredWithout(): iterable yield from [ ['nope', 'bars', false], ['foo', 'nope', true], - [null, null, false], - [null, 'foo', true], + ['', null, false], + ['', 'foo', true], ['foo', null, true], [ 'array.emptyField1', From 23b64e61abb3fe9ded7f28c9ed8c5b4790609847 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 21 Dec 2025 09:13:36 +0100 Subject: [PATCH 50/84] chore: remove IncomingRequest deprecations (#9851) * chore: remove IncomingRequest deprecations * update user guide --- system/HTTP/IncomingRequest.php | 134 +----------- .../HTTP/IncomingRequestDetectingTest.php | 196 ------------------ tests/system/HTTP/IncomingRequestTest.php | 33 +-- user_guide_src/source/changelogs/v4.7.0.rst | 8 +- .../source/incoming/incomingrequest.rst | 24 --- .../codeigniter.getReassignArray.neon | 7 +- .../codeigniter.superglobalAccess.neon | 17 +- .../codeigniter.superglobalAccessAssign.neon | 102 +-------- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 7 +- 10 files changed, 16 insertions(+), 514 deletions(-) delete mode 100644 tests/system/HTTP/IncomingRequestDetectingTest.php diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 260f7457fadc..7fb5cd85bca7 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -200,133 +200,6 @@ public function detectLocale($config) $this->setLocale($this->negotiate('language', $config->supportedLocales)); } - /** - * Sets up our URI object based on the information we have. This is - * either provided by the user in the baseURL Config setting, or - * determined from the environment as needed. - * - * @return void - * - * @deprecated 4.4.0 No longer used. - */ - protected function detectURI(string $protocol, string $baseURL) - { - $this->setPath($this->detectPath($this->config->uriProtocol), $this->config); - } - - /** - * Detects the relative path based on - * the URIProtocol Config setting. - * - * @deprecated 4.4.0 Moved to SiteURIFactory. - */ - public function detectPath(string $protocol = ''): string - { - if ($protocol === '') { - $protocol = 'REQUEST_URI'; - } - - $this->path = match ($protocol) { - 'REQUEST_URI' => $this->parseRequestURI(), - 'QUERY_STRING' => $this->parseQueryString(), - default => $this->fetchGlobal('server', $protocol) ?? $this->parseRequestURI(), - }; - - return $this->path; - } - - /** - * Will parse the REQUEST_URI and automatically detect the URI from it, - * fixing the query string if necessary. - * - * @return string The URI it found. - * - * @deprecated 4.4.0 Moved to SiteURIFactory. - */ - protected function parseRequestURI(): string - { - if (! isset($_SERVER['REQUEST_URI'], $_SERVER['SCRIPT_NAME'])) { - return ''; - } - - // parse_url() returns false if no host is present, but the path or query string - // contains a colon followed by a number. So we attach a dummy host since - // REQUEST_URI does not include the host. This allows us to parse out the query string and path. - $parts = parse_url('http://dummy' . $_SERVER['REQUEST_URI']); - $query = $parts['query'] ?? ''; - $uri = $parts['path'] ?? ''; - - // Strip the SCRIPT_NAME path from the URI - if ( - $uri !== '' && isset($_SERVER['SCRIPT_NAME'][0]) - && pathinfo($_SERVER['SCRIPT_NAME'], PATHINFO_EXTENSION) === 'php' - ) { - // Compare each segment, dropping them until there is no match - $segments = $keep = explode('/', $uri); - - foreach (explode('/', $_SERVER['SCRIPT_NAME']) as $i => $segment) { - // If these segments are not the same then we're done - if (! isset($segments[$i]) || $segment !== $segments[$i]) { - break; - } - - array_shift($keep); - } - - $uri = implode('/', $keep); - } - - // This section ensures that even on servers that require the URI to contain the query string (Nginx) a correct - // URI is found, and also fixes the QUERY_STRING Server var and $_GET array. - if (trim($uri, '/') === '' && str_starts_with($query, '/')) { - $query = explode('?', $query, 2); - $uri = $query[0]; - $_SERVER['QUERY_STRING'] = $query[1] ?? ''; - } else { - $_SERVER['QUERY_STRING'] = $query; - } - - // Update our globals for values likely to been have changed - parse_str($_SERVER['QUERY_STRING'], $_GET); - $this->populateGlobals('server'); - $this->populateGlobals('get'); - - $uri = URI::removeDotSegments($uri); - - return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); - } - - /** - * Parse QUERY_STRING - * - * Will parse QUERY_STRING and automatically detect the URI from it. - * - * @deprecated 4.4.0 Moved to SiteURIFactory. - */ - protected function parseQueryString(): string - { - $uri = $_SERVER['QUERY_STRING'] ?? @getenv('QUERY_STRING'); - - if (trim($uri, '/') === '') { - return '/'; - } - - if (str_starts_with($uri, '/')) { - $uri = explode('?', $uri, 2); - $_SERVER['QUERY_STRING'] = $uri[1] ?? ''; - $uri = $uri[0]; - } - - // Update our globals for values likely to been have changed - parse_str($_SERVER['QUERY_STRING'], $_GET); - $this->populateGlobals('server'); - $this->populateGlobals('get'); - - $uri = URI::removeDotSegments($uri); - - return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); - } - /** * Provides a convenient way to work with the Negotiate class * for content negotiation. @@ -411,14 +284,11 @@ public function isSecure(): bool * instance, this can be used to change the "current URL" * for testing. * - * @param string $path URI path relative to baseURL - * @param App|null $config Optional alternate config to use + * @param string $path URI path relative to baseURL * * @return $this - * - * @deprecated 4.4.0 This method will be private. The parameter $config is deprecated. No longer used. */ - public function setPath(string $path, ?App $config = null) + private function setPath(string $path) { $this->path = $path; diff --git a/tests/system/HTTP/IncomingRequestDetectingTest.php b/tests/system/HTTP/IncomingRequestDetectingTest.php deleted file mode 100644 index fa53060b6a83..000000000000 --- a/tests/system/HTTP/IncomingRequestDetectingTest.php +++ /dev/null @@ -1,196 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\HTTP; - -use CodeIgniter\Test\CIUnitTestCase; -use Config\App; -use PHPUnit\Framework\Attributes\BackupGlobals; -use PHPUnit\Framework\Attributes\Group; - -/** - * @internal - */ -#[BackupGlobals(true)] -#[Group('Others')] -final class IncomingRequestDetectingTest extends CIUnitTestCase -{ - private IncomingRequest $request; - - protected function setUp(): void - { - parent::setUp(); - - $_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = []; - - // The URI object is not used in detectPath(). - $this->request = new IncomingRequest(new App(), new SiteURI(new App(), 'woot?code=good#pos'), null, new UserAgent()); - } - - public function testPathDefault(): void - { - // /index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'woot'; - $this->assertSame($expected, $this->request->detectPath()); - } - - public function testPathDefaultEmpty(): void - { - // / - $_SERVER['REQUEST_URI'] = '/'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = '/'; - $this->assertSame($expected, $this->request->detectPath()); - } - - public function testPathRequestURI(): void - { - // /index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'woot'; - $this->assertSame($expected, $this->request->detectPath('REQUEST_URI')); - } - - public function testPathRequestURINested(): void - { - // I'm not sure but this is a case of Apache config making such SERVER - // values? - // The current implementation doesn't use the value of the URI object. - // So I removed the code to set URI. Therefore, it's exactly the same as - // the method above as a test. - // But it may be changed in the future to use the value of the URI object. - // So I don't remove this test case. - - // /ci/index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'woot'; - $this->assertSame($expected, $this->request->detectPath('REQUEST_URI')); - } - - public function testPathRequestURISubfolder(): void - { - // /ci/index.php/popcorn/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/ci/index.php/popcorn/woot'; - $_SERVER['SCRIPT_NAME'] = '/ci/index.php'; - - $expected = 'popcorn/woot'; - $this->assertSame($expected, $this->request->detectPath('REQUEST_URI')); - } - - public function testPathRequestURINoIndex(): void - { - // /sub/example - $_SERVER['REQUEST_URI'] = '/sub/example'; - $_SERVER['SCRIPT_NAME'] = '/sub/index.php'; - - $expected = 'example'; - $this->assertSame($expected, $this->request->detectPath('REQUEST_URI')); - } - - public function testPathRequestURINginx(): void - { - // /ci/index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'woot'; - $this->assertSame($expected, $this->request->detectPath('REQUEST_URI')); - } - - public function testPathRequestURINginxRedirecting(): void - { - // /?/ci/index.php/woot - $_SERVER['REQUEST_URI'] = '/?/ci/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'ci/woot'; - $this->assertSame($expected, $this->request->detectPath('REQUEST_URI')); - } - - public function testPathRequestURISuppressed(): void - { - // /woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/woot'; - $_SERVER['SCRIPT_NAME'] = '/'; - - $expected = 'woot'; - $this->assertSame($expected, $this->request->detectPath('REQUEST_URI')); - } - - public function testPathQueryString(): void - { - // /index.php?/ci/woot - $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot'; - $_SERVER['QUERY_STRING'] = '/ci/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'ci/woot'; - $this->assertSame($expected, $this->request->detectPath('QUERY_STRING')); - } - - public function testPathQueryStringWithQueryString(): void - { - // /index.php?/ci/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot?code=good'; - $_SERVER['QUERY_STRING'] = '/ci/woot?code=good'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'ci/woot'; - $this->assertSame($expected, $this->request->detectPath('QUERY_STRING')); - } - - public function testPathQueryStringEmpty(): void - { - // /index.php? - $_SERVER['REQUEST_URI'] = '/index.php?'; - $_SERVER['QUERY_STRING'] = ''; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = '/'; - $this->assertSame($expected, $this->request->detectPath('QUERY_STRING')); - } - - public function testPathPathInfo(): void - { - // /index.php/woot?code=good#pos - $this->request->setGlobal('server', [ - 'PATH_INFO' => null, - ]); - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'woot'; - $this->assertSame($expected, $this->request->detectPath('PATH_INFO')); - } - - public function testPathPathInfoGlobal(): void - { - // /index.php/woot?code=good#pos - $this->request->setGlobal('server', [ - 'PATH_INFO' => 'silliness', - ]); - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $expected = 'silliness'; - $this->assertSame($expected, $this->request->detectPath('PATH_INFO')); - } -} diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index a26039374e18..7829786f7f4e 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -909,36 +909,6 @@ public function testGetPostIndexNotExists(): void $this->assertNull($this->request->getGetPost('gc')); } - /** - * @param mixed $path - * @param mixed $detectPath - */ - #[DataProvider('provideExtensionPHP')] - public function testExtensionPHP($path, $detectPath): void - { - $config = new App(); - $config->baseURL = 'http://example.com/'; - - $_SERVER['REQUEST_URI'] = $path; - $_SERVER['SCRIPT_NAME'] = $path; - $request = new IncomingRequest($config, new SiteURI($config, $path), null, new UserAgent()); - $this->assertSame($detectPath, $request->detectPath()); - } - - public static function provideExtensionPHP(): iterable - { - return [ - 'not /index.php' => [ - '/test.php', - '/', - ], - '/index.php' => [ - '/index.php', - '/', - ], - ]; - } - public function testGetPath(): void { $request = $this->createRequest(null, null, 'fruits/banana'); @@ -952,7 +922,8 @@ public function testSetPath(): void $request = new IncomingRequest($config, new SiteURI($config), null, new UserAgent()); $this->assertSame('', $request->getPath()); - $request->setPath('foobar'); + $setPath = $this->getPrivateMethodInvoker($request, 'setPath'); + $setPath('foobar'); $this->assertSame('foobar', $request->getPath()); } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 70b27b15a600..8d1f46962e25 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -100,10 +100,16 @@ Method Signature Changes Removed Deprecated Items ======================== -- **Text Helper:** The deprecated types in ``random_string()`` function: ``basic``, ``md5``, and ``sha1`` has been removed. - **BaseModel:** The deprecated method ``transformDataRowToArray()`` has been removed. - **CodeIgniter:** The deprecated ``CodeIgniter\CodeIgniter::resolvePlatformExtensions()`` has been removed. - **Cache:** The deprecated return type ``false`` for ``CodeIgniter\Cache\CacheInterface::getMetaData()`` has been replaced with ``null`` type. +- **IncomingRequest:** The deprecated methods has been removed: + - ``CodeIgniter\HTTP\IncomingRequest\detectURI()`` + - ``CodeIgniter\HTTP\IncomingRequest\detectPath()`` + - ``CodeIgniter\HTTP\IncomingRequest\parseRequestURI()`` + - ``CodeIgniter\HTTP\IncomingRequest\parseQueryString()`` +- **IncomingRequest:** The deprecated ``$config`` parameter has been removed from ``CodeIgniter\HTTP\IncomingRequest::setPath()``, and the method visibility has been changed from ``public`` to ``private``. +- **Text Helper:** The deprecated types in ``random_string()`` function: ``basic``, ``md5``, and ``sha1`` has been removed. ************ Enhancements diff --git a/user_guide_src/source/incoming/incomingrequest.rst b/user_guide_src/source/incoming/incomingrequest.rst index e22c4ce37773..a96353dadfba 100644 --- a/user_guide_src/source/incoming/incomingrequest.rst +++ b/user_guide_src/source/incoming/incomingrequest.rst @@ -131,16 +131,6 @@ The ``getServer()`` method will pull from ``$_SERVER``. * ``$request->getServer()`` -getEnv() --------- - -.. deprecated:: 4.4.4 This method does not work from the beginning. Use - :php:func:`env()` instead. - -The ``getEnv()`` method will pull from ``$_ENV``. - -* ``$request->getEnv()`` - getPostGet() ------------ @@ -325,7 +315,6 @@ The methods provided by the parent classes that are available are: * :meth:`CodeIgniter\\HTTP\\Request::getMethod` * :meth:`CodeIgniter\\HTTP\\Request::setMethod` * :meth:`CodeIgniter\\HTTP\\Request::getServer` -* :meth:`CodeIgniter\\HTTP\\Request::getEnv` * :meth:`CodeIgniter\\HTTP\\Request::setGlobal` * :meth:`CodeIgniter\\HTTP\\Request::fetchGlobal` * :meth:`CodeIgniter\\HTTP\\Message::getBody` @@ -531,16 +520,3 @@ The methods provided by the parent classes that are available are: "current URI", since ``IncomingRequest::$uri`` might not be aware of the complete App configuration for base URLs. - .. php:method:: setPath($path) - - .. deprecated:: 4.4.0 - - :param string $path: The relative path to use as the current URI - :returns: This Incoming Request - :rtype: IncomingRequest - - .. note:: Prior to v4.4.0, used mostly just for testing purposes, this - allowed you to set the relative path value for the current request - instead of relying on URI detection. This also updated the - underlying ``URI`` instance with the new path. - diff --git a/utils/phpstan-baseline/codeigniter.getReassignArray.neon b/utils/phpstan-baseline/codeigniter.getReassignArray.neon index b661a9e9d7de..6c45e13961e4 100644 --- a/utils/phpstan-baseline/codeigniter.getReassignArray.neon +++ b/utils/phpstan-baseline/codeigniter.getReassignArray.neon @@ -1,4 +1,4 @@ -# total 19 errors +# total 18 errors parameters: ignoreErrors: @@ -17,11 +17,6 @@ parameters: count: 1 path: ../../tests/system/HTTP/CLIRequestTest.php - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' count: 1 diff --git a/utils/phpstan-baseline/codeigniter.superglobalAccess.neon b/utils/phpstan-baseline/codeigniter.superglobalAccess.neon index 00869afc1f4e..ab837de286c5 100644 --- a/utils/phpstan-baseline/codeigniter.superglobalAccess.neon +++ b/utils/phpstan-baseline/codeigniter.superglobalAccess.neon @@ -1,4 +1,4 @@ -# total 68 errors +# total 59 errors parameters: ignoreErrors: @@ -72,21 +72,6 @@ parameters: count: 2 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Accessing offset ''QUERY_STRING'' directly on \$_SERVER is discouraged\.$#' - count: 3 - path: ../../system/HTTP/IncomingRequest.php - - - - message: '#^Accessing offset ''REQUEST_URI'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../system/HTTP/IncomingRequest.php - - - - message: '#^Accessing offset ''SCRIPT_NAME'' directly on \$_SERVER is discouraged\.$#' - count: 4 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Accessing offset array\|string directly on \$_GET is discouraged\.$#' count: 2 diff --git a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon b/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon index 71e76f0cfe41..c557d777faf5 100644 --- a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon +++ b/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon @@ -1,4 +1,4 @@ -# total 459 errors +# total 423 errors parameters: ignoreErrors: @@ -12,11 +12,6 @@ parameters: count: 1 path: ../../system/Config/DotEnv.php - - - message: '#^Assigning string directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Assigning ''http\://example\.com/'' directly on offset ''app\.baseURL'' of \$_SERVER is discouraged\.$#' count: 1 @@ -187,91 +182,6 @@ parameters: count: 1 path: ../../tests/system/HTTP/DownloadResponseTest.php - - - message: '#^Assigning '''' directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/\?/ci/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/ci/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/ci/index\.php/popcorn/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/ci/woot'' directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/ci/woot\?code\=good'' directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 11 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/index\.php/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 5 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/index\.php/woot\?code\=good'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/index\.php\?'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/index\.php\?/ci/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/index\.php\?/ci/woot\?code\=good'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/sub/example'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/sub/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - - - message: '#^Assigning ''/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestDetectingTest.php - - message: '#^Assigning ''10\.0\.1\.200'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' count: 2 @@ -362,16 +272,6 @@ parameters: count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php - - - message: '#^Assigning mixed directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning mixed directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' count: 1 diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 50220a8e8989..f175a7113eb2 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2708 errors +# total 2661 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 9d944d2ed357..795c0ed3d034 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1320 errors +# total 1319 errors parameters: ignoreErrors: @@ -5387,11 +5387,6 @@ parameters: count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequestTest\:\:provideExtensionPHP\(\) return type has no value type specified in iterable type iterable\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequestTest\:\:provideIsHTTPMethods\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 From 0bd3e098f731b454c79299b9e77d05fd3460bd6e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Mon, 22 Dec 2025 18:51:21 +0100 Subject: [PATCH 51/84] docs: improve wording and clarity (#9850) --- README.md | 2 +- user_guide_src/source/intro/requirements.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d7d633ac28b7..6a4e1025a8f5 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ PHP version 8.2 or higher is required, with the following extensions installed: > - The end of life date for PHP 7.4 was November 28, 2022. > - The end of life date for PHP 8.0 was November 26, 2023. > - The end of life date for PHP 8.1 was December 31, 2025. -> - If you are still using below PHP 8.2, you should upgrade immediately. +> - If you are still using a PHP version below 8.2, you should upgrade immediately. > - The end of life date for PHP 8.2 will be December 31, 2026. Additionally, make sure that the following extensions are enabled in your PHP: diff --git a/user_guide_src/source/intro/requirements.rst b/user_guide_src/source/intro/requirements.rst index 2de0b5c5b7d3..d5ec8adf0d34 100644 --- a/user_guide_src/source/intro/requirements.rst +++ b/user_guide_src/source/intro/requirements.rst @@ -19,7 +19,7 @@ PHP and Required Extensions - The end of life date for PHP 7.4 was November 28, 2022. - The end of life date for PHP 8.0 was November 26, 2023. - The end of life date for PHP 8.1 was December 31, 2025. - - **If you are still using below PHP 8.2, you should upgrade immediately.** + - **If you are still using a PHP version below 8.2, you should upgrade immediately.** - The end of life date for PHP 8.2 will be December 31, 2026. .. note:: From fe1e9446266741f876fea85c8b18f85667acab30 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 26 Dec 2025 10:38:06 +0100 Subject: [PATCH 52/84] feat(model): primary key validation (#9840) * feat: primary key validation in model * phpstan baseline * primary key validation in insertBatch * add changelog * cs fix * update user guide * fix tests for postgre * allow RawSql for the primary key * update phpstan * Update system/BaseModel.php Co-authored-by: John Paul E. Balandan, CPA * cs fix --------- Co-authored-by: John Paul E. Balandan, CPA --- system/BaseModel.php | 90 ++++++++-- system/Model.php | 16 +- tests/system/Models/DeleteModelTest.php | 154 +++++++++++++++--- tests/system/Models/InsertModelTest.php | 106 ++++++++++++ tests/system/Models/UpdateModelTest.php | 80 ++++++++- user_guide_src/source/changelogs/v4.7.0.rst | 40 +++++ user_guide_src/source/models/model.rst | 13 ++ utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 12 +- .../missingType.property.neon | 80 ++++----- 10 files changed, 494 insertions(+), 99 deletions(-) diff --git a/system/BaseModel.php b/system/BaseModel.php index 6b2edc43947f..06af2ded411f 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -19,6 +19,7 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Database\Query; +use CodeIgniter\Database\RawSql; use CodeIgniter\DataCaster\Cast\CastInterface; use CodeIgniter\DataConverter\DataConverter; use CodeIgniter\Entity\Cast\CastInterface as EntityCastInterface; @@ -780,6 +781,67 @@ public function getInsertID() return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID; } + /** + * Validates that the primary key values are valid for update/delete/insert operations. + * Throws exception if invalid. + * + * @param bool $allowArray Whether to allow array of IDs (true for update/delete, false for insert) + * + * @phpstan-assert non-zero-int|non-empty-list|RawSql|non-falsy-string $id + * @throws InvalidArgumentException + */ + protected function validateID(mixed $id, bool $allowArray = true): void + { + if (is_array($id)) { + // Check if arrays are allowed + if (! $allowArray) { + throw new InvalidArgumentException( + 'Invalid primary key: only a single value is allowed, not an array.', + ); + } + + // Check for empty array + if ($id === []) { + throw new InvalidArgumentException('Invalid primary key: cannot be an empty array.'); + } + + // Validate each ID in the array recursively + foreach ($id as $key => $valueId) { + if (is_array($valueId)) { + throw new InvalidArgumentException( + sprintf('Invalid primary key at index %s: nested arrays are not allowed.', $key), + ); + } + + // Recursive call for each value (single values only in recursion) + $this->validateID($valueId, false); + } + + return; + } + + // Allow RawSql objects for complex scenarios + if ($id instanceof RawSql) { + return; + } + + // Check for invalid single values + if (in_array($id, [null, 0, '0', '', true, false], true)) { + $type = is_bool($id) ? 'boolean ' . var_export($id, true) : var_export($id, true); + + throw new InvalidArgumentException( + sprintf('Invalid primary key: %s is not allowed.', $type), + ); + } + + // Only allow int and string at this point + if (! is_int($id) && ! is_string($id)) { + throw new InvalidArgumentException( + sprintf('Invalid primary key: must be int or string, %s given.', get_debug_type($id)), + ); + } + } + /** * Inserts data into the database. If an object is provided, * it will attempt to convert it to an array. @@ -962,19 +1024,19 @@ public function insertBatch(?array $set = null, ?bool $escape = null, int $batch * Updates a single record in the database. If an object is provided, * it will attempt to convert it into an array. * - * @param int|list|string|null $id - * @param object|row_array|null $row + * @param int|list|RawSql|string|null $id + * @param object|row_array|null $row * * @throws ReflectionException */ public function update($id = null, $row = null): bool { - if (is_bool($id)) { - throw new InvalidArgumentException('update(): argument #1 ($id) should not be boolean.'); - } + if ($id !== null) { + if (! is_array($id)) { + $id = [$id]; + } - if (is_numeric($id) || is_string($id)) { - $id = [$id]; + $this->validateID($id); } $row = $this->transformDataToArray($row, 'update'); @@ -1091,8 +1153,8 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc /** * Deletes a single record from the database where $id matches. * - * @param int|list|string|null $id The rows primary key(s). - * @param bool $purge Allows overriding the soft deletes setting. + * @param int|list|RawSql|string|null $id The rows primary key(s). + * @param bool $purge Allows overriding the soft deletes setting. * * @return bool|string Returns a SQL string if in test mode. * @@ -1100,12 +1162,12 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc */ public function delete($id = null, bool $purge = false) { - if (is_bool($id)) { - throw new InvalidArgumentException('delete(): argument #1 ($id) should not be boolean.'); - } + if ($id !== null) { + if (! is_array($id)) { + $id = [$id]; + } - if (! in_array($id, [null, 0, '0'], true) && (is_numeric($id) || is_string($id))) { - $id = [$id]; + $this->validateID($id); } $eventData = [ diff --git a/system/Model.php b/system/Model.php index 108ff17cf849..dc3e4db94db2 100644 --- a/system/Model.php +++ b/system/Model.php @@ -311,8 +311,13 @@ protected function doInsert(array $row) // Require non-empty primaryKey when // not using auto-increment feature - if (! $this->useAutoIncrement && ! isset($row[$this->primaryKey])) { - throw DataException::forEmptyPrimaryKey('insert'); + if (! $this->useAutoIncrement) { + if (! isset($row[$this->primaryKey])) { + throw DataException::forEmptyPrimaryKey('insert'); + } + + // Validate the primary key value (arrays not allowed for insert) + $this->validateID($row[$this->primaryKey], false); } $builder = $this->builder(); @@ -368,6 +373,9 @@ protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $ if (! isset($row[$this->primaryKey])) { throw DataException::forEmptyPrimaryKey('insertBatch'); } + + // Validate the primary key value + $this->validateID($row[$this->primaryKey], false); } } @@ -381,7 +389,7 @@ protected function doUpdate($id = null, $row = null): bool $builder = $this->builder(); - if (! in_array($id, [null, '', 0, '0', []], true)) { + if (is_array($id) && $id !== []) { $builder = $builder->whereIn($this->table . '.' . $this->primaryKey, $id); } @@ -409,7 +417,7 @@ protected function doDelete($id = null, bool $purge = false) $set = []; $builder = $this->builder(); - if (! in_array($id, [null, '', 0, '0', []], true)) { + if (is_array($id) && $id !== []) { $builder = $builder->whereIn($this->primaryKey, $id); } diff --git a/tests/system/Models/DeleteModelTest.php b/tests/system/Models/DeleteModelTest.php index 0294ab32aed9..9d97ca50fa74 100644 --- a/tests/system/Models/DeleteModelTest.php +++ b/tests/system/Models/DeleteModelTest.php @@ -14,6 +14,8 @@ namespace CodeIgniter\Models; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\RawSql; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\ModelException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -37,6 +39,17 @@ public function testDeleteBasics(): void $this->dontSeeInDatabase('job', ['name' => 'Developer']); } + public function testDeleteWithRawSql(): void + { + $this->createModel(JobModel::class); + $this->seeInDatabase('job', ['name' => 'Developer']); + + // RawSql objects should be allowed as primary key values + $result = $this->model->delete(new RawSql('1')); + $this->assertTrue($result); + $this->dontSeeInDatabase('job', ['name' => 'Developer']); + } + public function testDeleteFail(): void { // WARNING this value will persist! take care to roll it back. @@ -152,29 +165,60 @@ public function testOnlyDeleted(): void /** * Given an explicit empty value in the WHERE condition - * When executing a soft delete + * When executing a soft delete with where() clause * Then an exception should not be thrown * + * This test uses where() so values go into WHERE clause, not through validateID(). + * * @param int|string|null $emptyValue */ - #[DataProvider('emptyPkValues')] + #[DataProvider('provideDontThrowExceptionWhenSoftDeleteConditionIsSetWithEmptyValue')] public function testDontThrowExceptionWhenSoftDeleteConditionIsSetWithEmptyValue($emptyValue): void { $this->createModel(UserModel::class); + + if ($this->db->DBDriver === 'Postgre' && in_array($emptyValue, ['', true, false], true)) { + $this->markTestSkipped('PostgreSQL does not allow empty string, true, or false for integer columns'); + } + $this->seeInDatabase('user', ['name' => 'Derek Jones', 'deleted_at IS NULL' => null]); $this->model->where('id', $emptyValue)->delete(); - $this->seeInDatabase('user', ['name' => 'Derek Jones', 'deleted_at IS NULL' => null]); + // Special case: true converted to 1 + if ($emptyValue === true) { + $this->seeInDatabase('user', ['name' => 'Derek Jones', 'deleted_at IS NOT NULL' => null]); + } else { + $this->seeInDatabase('user', ['name' => 'Derek Jones', 'deleted_at IS NULL' => null]); + } + } + + /** + * Data provider for tests using where() clause. + * These values go into WHERE clause, not through validateID(). + * + * @return iterable + */ + public static function provideDontThrowExceptionWhenSoftDeleteConditionIsSetWithEmptyValue(): iterable + { + return [ + [0], + [null], + ['0'], + [''], + [true], + [false], + ]; } /** * @param int|string|null $emptyValue + * @param class-string $exception */ #[DataProvider('emptyPkValues')] - public function testThrowExceptionWhenSoftDeleteParamIsEmptyValue($emptyValue): void + public function testThrowExceptionWhenSoftDeleteParamIsEmptyValue($emptyValue, string $exception, string $exceptionMessage): void { - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Deletes are not allowed unless they contain a "where" or "like" clause.'); + $this->expectException($exception); + $this->expectExceptionMessage($exceptionMessage); $this->seeInDatabase('user', ['name' => 'Derek Jones', 'deleted_at IS NULL' => null]); @@ -183,16 +227,17 @@ public function testThrowExceptionWhenSoftDeleteParamIsEmptyValue($emptyValue): /** * @param int|string|null $emptyValue + * @param class-string $exception */ #[DataProvider('emptyPkValues')] - public function testDontDeleteRowsWhenSoftDeleteParamIsEmpty($emptyValue): void + public function testDontDeleteRowsWhenSoftDeleteParamIsEmpty($emptyValue, string $exception, string $exceptionMessage): void { $this->seeInDatabase('user', ['name' => 'Derek Jones', 'deleted_at IS NULL' => null]); try { $this->createModel(UserModel::class)->delete($emptyValue); - } catch (DatabaseException) { - // Do nothing. + } catch (DatabaseException|InvalidArgumentException) { + // Do nothing - both exceptions are expected for different values. } $this->seeInDatabase('user', ['name' => 'Derek Jones', 'deleted_at IS NULL' => null]); @@ -233,15 +278,13 @@ public function testPurgeDeletedWithSoftDeleteFalse(): void /** * @param int|string|null $id + * @param class-string $exception */ #[DataProvider('emptyPkValues')] - public function testDeleteThrowDatabaseExceptionWithoutWhereClause($id): void + public function testDeleteThrowDatabaseExceptionWithoutWhereClause($id, string $exception, string $exceptionMessage): void { - // BaseBuilder throws Exception. - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage( - 'Deletes are not allowed unless they contain a "where" or "like" clause.', - ); + $this->expectException($exception); + $this->expectExceptionMessage($exceptionMessage); // $useSoftDeletes = false $this->createModel(JobModel::class); @@ -251,15 +294,13 @@ public function testDeleteThrowDatabaseExceptionWithoutWhereClause($id): void /** * @param int|string|null $id + * @param class-string $exception */ #[DataProvider('emptyPkValues')] - public function testDeleteWithSoftDeleteThrowDatabaseExceptionWithoutWhereClause($id): void + public function testDeleteWithSoftDeleteThrowDatabaseExceptionWithoutWhereClause($id, string $exception, string $exceptionMessage): void { - // Model throws Exception. - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage( - 'Deletes are not allowed unless they contain a "where" or "like" clause.', - ); + $this->expectException($exception); + $this->expectExceptionMessage($exceptionMessage); // $useSoftDeletes = true $this->createModel(UserModel::class); @@ -267,12 +308,77 @@ public function testDeleteWithSoftDeleteThrowDatabaseExceptionWithoutWhereClause $this->model->delete($id); } + /** + * @return iterable + */ public static function emptyPkValues(): iterable { return [ - [0], - [null], - ['0'], + 'null' => [ + null, + DatabaseException::class, + 'Deletes are not allowed unless they contain a "where" or "like" clause.', + ], + 'false' => [ + false, + InvalidArgumentException::class, + 'Invalid primary key: boolean false is not allowed.', + ], + '0 integer' => [ + 0, + InvalidArgumentException::class, + 'Invalid primary key: 0 is not allowed.', + ], + "'0' string" => [ + '0', + InvalidArgumentException::class, + "Invalid primary key: '0' is not allowed.", + ], + 'empty string' => [ + '', + InvalidArgumentException::class, + "Invalid primary key: '' is not allowed.", + ], + 'true' => [ + true, + InvalidArgumentException::class, + 'Invalid primary key: boolean true is not allowed.', + ], + 'empty array' => [ + [], + InvalidArgumentException::class, + 'Invalid primary key: cannot be an empty array.', + ], + 'nested array' => [ + [[1, 2]], + InvalidArgumentException::class, + 'Invalid primary key at index 0: nested arrays are not allowed.', + ], + 'array with null' => [ + [1, null, 3], + InvalidArgumentException::class, + 'Invalid primary key: NULL is not allowed.', + ], + 'array with 0' => [ + [1, 0, 3], + InvalidArgumentException::class, + 'Invalid primary key: 0 is not allowed.', + ], + "array with '0'" => [ + [1, '0', 3], + InvalidArgumentException::class, + "Invalid primary key: '0' is not allowed.", + ], + 'array with empty string' => [ + [1, '', 3], + InvalidArgumentException::class, + "Invalid primary key: '' is not allowed.", + ], + 'array with boolean' => [ + [1, false, 3], + InvalidArgumentException::class, + 'Invalid primary key: boolean false is not allowed.', + ], ]; } } diff --git a/tests/system/Models/InsertModelTest.php b/tests/system/Models/InsertModelTest.php index 11b079be3ab4..baf6b45ea7f0 100644 --- a/tests/system/Models/InsertModelTest.php +++ b/tests/system/Models/InsertModelTest.php @@ -15,9 +15,11 @@ use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Entity\Entity; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use CodeIgniter\Model; use Config\Database; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use stdClass; use Tests\Support\Entity\User; @@ -407,4 +409,108 @@ public function testInsertBatchWithCasts(): void $this->seeInDatabase('user', ['email' => json_encode($userData[0]['email'])]); $this->seeInDatabase('user', ['email' => json_encode($userData[1]['email'])]); } + + /** + * @param mixed $invalidKey + * @param class-string $exception + */ + #[DataProvider('provideInvalidPrimaryKeyValues')] + public function testInsertWithInvalidPrimaryKeyWhenAutoIncrementDisabled( + $invalidKey, + string $exception, + string $exceptionMessage, + ): void { + $this->expectException($exception); + $this->expectExceptionMessage($exceptionMessage); + + $insert = [ + 'key' => $invalidKey, + 'value' => 'some value', + ]; + + $this->createModel(WithoutAutoIncrementModel::class)->insert($insert); + } + + /** + * @param mixed $invalidKey + * @param class-string $exception + */ + #[DataProvider('provideInvalidPrimaryKeyValues')] + public function testInsertBatchWithInvalidPrimaryKeyWhenAutoIncrementDisabled($invalidKey, string $exception, string $exceptionMessage): void + { + $this->expectException($exception); + $this->expectExceptionMessage($exceptionMessage); + + $insertData = [ + [ + 'key' => 'valid_key_1', + 'value' => 'value1', + ], + [ + 'key' => $invalidKey, // Invalid key in second row + 'value' => 'value2', + ], + ]; + + $this->createModel(WithoutAutoIncrementModel::class)->insertBatch($insertData); + } + + /** + * @return iterable + */ + public static function provideInvalidPrimaryKeyValues(): iterable + { + return [ + 'null' => [ + null, + DataException::class, + 'There is no primary key defined when trying to make insert', + ], + '0 integer' => [ + 0, + InvalidArgumentException::class, + 'Invalid primary key: 0 is not allowed.', + ], + "'0' string" => [ + '0', + InvalidArgumentException::class, + "Invalid primary key: '0' is not allowed.", + ], + 'empty string' => [ + '', + InvalidArgumentException::class, + "Invalid primary key: '' is not allowed.", + ], + 'true' => [ + true, + InvalidArgumentException::class, + 'Invalid primary key: boolean true is not allowed.', + ], + 'false' => [ + false, + InvalidArgumentException::class, + 'Invalid primary key: boolean false is not allowed.', + ], + 'array with null' => [ + [null], + InvalidArgumentException::class, + 'Invalid primary key: only a single value is allowed, not an array.', + ], + 'array with 0 integer' => [ + [0], + InvalidArgumentException::class, + 'Invalid primary key: only a single value is allowed, not an array.', + ], + "array with '0' string" => [ + ['0'], + InvalidArgumentException::class, + 'Invalid primary key: only a single value is allowed, not an array.', + ], + 'array with empty array' => [ + [[]], + InvalidArgumentException::class, + 'Invalid primary key: only a single value is allowed, not an array.', + ], + ]; + } } diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index a9380f34c771..94f8df12ea73 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\RawSql; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database; @@ -107,6 +108,16 @@ public function testUpdateArray(): void $this->seeInDatabase('user', ['id' => 2, 'name' => 'Foo Bar']); } + public function testUpdateWithRawSql(): void + { + $this->createModel(UserModel::class); + + // RawSql objects should be allowed as primary key values + $result = $this->model->update(new RawSql('1'), ['name' => 'RawSql User']); + $this->assertTrue($result); + $this->seeInDatabase('user', ['id' => 1, 'name' => 'RawSql User']); + } + public function testUpdateResultFail(): void { // WARNING this value will persist! take care to roll it back. @@ -550,7 +561,8 @@ public function testUpdateWithSetAndEscape(): void } /** - * @param false|null $id + * @param bool|int|string|null $id + * @param class-string $exception */ #[DataProvider('provideUpdateThrowDatabaseExceptionWithoutWhereClause')] public function testUpdateThrowDatabaseExceptionWithoutWhereClause($id, string $exception, string $exceptionMessage): void @@ -561,21 +573,79 @@ public function testUpdateThrowDatabaseExceptionWithoutWhereClause($id, string $ // $useSoftDeletes = false $this->createModel(JobModel::class); - $this->model->update($id, ['name' => 'Foo Bar']); // @phpstan-ignore argument.type + $this->model->update($id, ['name' => 'Foo Bar']); } + /** + * @return iterable + */ public static function provideUpdateThrowDatabaseExceptionWithoutWhereClause(): iterable { yield from [ - [ + 'null' => [ null, DatabaseException::class, 'Updates are not allowed unless they contain a "where" or "like" clause.', ], - [ + 'false' => [ false, InvalidArgumentException::class, - 'update(): argument #1 ($id) should not be boolean.', + 'Invalid primary key: boolean false is not allowed.', + ], + 'true' => [ + true, + InvalidArgumentException::class, + 'Invalid primary key: boolean true is not allowed.', + ], + '0 integer' => [ + 0, + InvalidArgumentException::class, + 'Invalid primary key: 0 is not allowed.', + ], + "'0' string" => [ + '0', + InvalidArgumentException::class, + "Invalid primary key: '0' is not allowed.", + ], + 'empty string' => [ + '', + InvalidArgumentException::class, + "Invalid primary key: '' is not allowed.", + ], + 'empty array' => [ + [], + InvalidArgumentException::class, + 'Invalid primary key: cannot be an empty array.', + ], + 'nested array' => [ + [[1, 2]], + InvalidArgumentException::class, + 'Invalid primary key at index 0: nested arrays are not allowed.', + ], + 'array with null' => [ + [1, null, 3], + InvalidArgumentException::class, + 'Invalid primary key: NULL is not allowed.', + ], + 'array with 0' => [ + [1, 0, 3], + InvalidArgumentException::class, + 'Invalid primary key: 0 is not allowed.', + ], + "array with '0'" => [ + [1, '0', 3], + InvalidArgumentException::class, + "Invalid primary key: '0' is not allowed.", + ], + 'array with empty string' => [ + [1, '', 3], + InvalidArgumentException::class, + "Invalid primary key: '' is not allowed.", + ], + 'array with boolean' => [ + [1, false, 3], + InvalidArgumentException::class, + 'Invalid primary key: boolean false is not allowed.', ], ]; } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 8d1f46962e25..5cfbbf6815f5 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -40,6 +40,46 @@ The ``insertBatch()`` and ``updateBatch()`` methods now honor model settings lik ``updateOnlyChanged`` and ``allowEmptyInserts``. This change ensures consistent handling across all insert/update operations. +Primary Key Validation +^^^^^^^^^^^^^^^^^^^^^^ + +The ``insert()`` and ``insertBatch()`` (when ``useAutoIncrement`` is disabled), ``update()``, +and ``delete()`` methods now validate primary key values **before** executing database queries. +Invalid primary key values will now throw ``InvalidArgumentException`` instead of +``DatabaseException``. + +**What changed:** + +- **Exception type:** Invalid primary keys now throw ``InvalidArgumentException`` with + specific error messages instead of generic ``DatabaseException`` from the database layer. +- **Validation timing:** + + - For ``update()`` and ``delete()``: Validation happens **before** the ``beforeUpdate``/``beforeDelete`` + events and before any database queries are executed. + - For ``insert()`` and ``insertBatch()`` (when auto-increment is disabled): Validation happens + **after** the ``beforeInsert`` event but **before** database queries are executed. + +- **Invalid values:** The following values are now explicitly rejected as primary keys: + + - ``null`` (for insert when auto-increment is disabled) + - ``0`` (integer zero) + - ``'0'`` (string zero) + - ``''`` (empty string) + - ``true`` and ``false`` (booleans) + - ``[]`` (empty array) + - Nested arrays (e.g., ``[[1, 2]]``) + - Arrays containing any of the above invalid values + + **Note:** ``RawSql`` objects are allowed as primary key values for complex scenarios + where you need to use raw SQL expressions. + +This change improves error reporting by providing specific validation messages +(e.g., "Invalid primary key: 0 is not allowed") instead of generic database errors, and +prevents invalid queries from reaching the database. + +If you need to allow some of these values for the primary key, you can override the +``validateID()`` method in your model. + Entity ------ diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index c9be9878e094..f1383b45f803 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -648,6 +648,19 @@ converted to strings with the format defined in ``dateFormat['datetime']`` and .. note:: Prior to v4.5.0, the date/time formats were hard coded as ``Y-m-d H:i:s`` and ``Y-m-d`` in the Model class. +Primary Key Validation +---------------------- + +.. versionadded:: 4.7.0 + +The ``insert()``, ``insertBatch()`` (when `$useAutoIncrement`_ is ``false``), ``update()``, +and ``delete()`` methods validate primary key values before executing database queries. +Invalid values such as ``null``, ``0``, ``'0'``, empty strings, booleans, empty arrays, +or nested arrays will throw an ``InvalidArgumentException`` with a specific error message. + +If you need to customize this behavior (e.g., to allow ``0`` as a valid primary key for +legacy systems), you can override the ``validateID()`` method in your model. + Deleting Data ============= diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index f175a7113eb2..3eb1f3bd917f 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2661 errors +# total 2659 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 795c0ed3d034..36fa344cd39e 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1319 errors +# total 1317 errors parameters: ignoreErrors: @@ -5792,11 +5792,6 @@ parameters: count: 1 path: ../../tests/system/I18n/TimeTest.php - - - message: '#^Method CodeIgniter\\Models\\DeleteModelTest\:\:emptyPkValues\(\) return type has no value type specified in iterable type iterable\.$#' - count: 1 - path: ../../tests/system/Models/DeleteModelTest.php - - message: '#^Method CodeIgniter\\Models\\FindModelTest\:\:provideAggregateAndGroupBy\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 @@ -5817,11 +5812,6 @@ parameters: count: 1 path: ../../tests/system/Models/TimestampModelTest.php - - - message: '#^Method CodeIgniter\\Models\\UpdateModelTest\:\:provideUpdateThrowDatabaseExceptionWithoutWhereClause\(\) return type has no value type specified in iterable type iterable\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Method CodeIgniter\\Publisher\\PublisherRestrictionsTest\:\:provideDefaultPublicRestrictions\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index 48a8f1e1f48d..139386d26a14 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -243,82 +243,82 @@ parameters: path: ../../tests/system/Database/Live/MySQLi/NumberNativeTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:194\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:196\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:288\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/InsertModelTest\.php\:290\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/InsertModelTest.php @@ -393,122 +393,122 @@ parameters: path: ../../tests/system/Models/SaveModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:199\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:210\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:218\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:229\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$_options has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$_options has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$country has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$country has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$created_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$created_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$deleted has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$deleted has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$email has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$email has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$id has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$id has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$name has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$name has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php - - message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:353\:\:\$updated_at has no type specified\.$#' + message: '#^Property CodeIgniter\\Entity\\Entity@anonymous/tests/system/Models/UpdateModelTest\.php\:364\:\:\$updated_at has no type specified\.$#' count: 1 path: ../../tests/system/Models/UpdateModelTest.php From 877d1da26992dfeb835b95b01c5bfc0b8ed8669d Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sat, 27 Dec 2025 09:05:00 +0100 Subject: [PATCH 53/84] feat(entity): properly convert arrays of entities in `toRawArray()` (#9841) * feat: make toRawArray() properly convert arrays of entities * add changelog * update changelog --- system/Entity/Entity.php | 127 +++++++++++++++++--- tests/system/Entity/EntityTest.php | 126 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 5 + 3 files changed, 241 insertions(+), 17 deletions(-) diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 59d18e9fe8a5..745b4cbe3dae 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -232,37 +232,109 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu */ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array { - $return = []; + $convert = static function ($value) use (&$convert, $recursive) { + if (! $recursive) { + return $value; + } - if (! $onlyChanged) { - if ($recursive) { - return array_map(static function ($value) use ($onlyChanged, $recursive) { - if ($value instanceof self) { - $value = $value->toRawArray($onlyChanged, $recursive); - } elseif (is_callable([$value, 'toRawArray'])) { - $value = $value->toRawArray(); - } + if ($value instanceof self) { + // Always output full array for nested entities + return $value->toRawArray(false, true); + } + + if (is_array($value)) { + $result = []; - return $value; - }, $this->attributes); + foreach ($value as $k => $v) { + $result[$k] = $convert($v); + } + + return $result; + } + + if (is_object($value) && is_callable([$value, 'toRawArray'])) { + return $value->toRawArray(); } - return $this->attributes; + return $value; + }; + + // When returning everything + if (! $onlyChanged) { + return $recursive + ? array_map($convert, $this->attributes) + : $this->attributes; } + // When filtering by changed values only + $return = []; + foreach ($this->attributes as $key => $value) { + // Special handling for arrays of entities in recursive mode + // Skip hasChanged() and do per-entity comparison directly + if ($recursive && is_array($value) && $this->containsOnlyEntities($value)) { + $originalValue = $this->original[$key] ?? null; + + if (! is_string($originalValue)) { + // No original or invalid format, export all entities + $converted = []; + + foreach ($value as $idx => $item) { + $converted[$idx] = $item->toRawArray(false, true); + } + $return[$key] = $converted; + + continue; + } + + // Decode original array structure for per-entity comparison + $originalArray = json_decode($originalValue, true); + $converted = []; + + foreach ($value as $idx => $item) { + // Compare current entity against its original state + $currentNormalized = $this->normalizeValue($item); + $originalNormalized = $originalArray[$idx] ?? null; + + // Only include if changed, new, or can't determine + if ($originalNormalized === null || $currentNormalized !== $originalNormalized) { + $converted[$idx] = $item->toRawArray(false, true); + } + } + + // Only include this property if at least one entity changed + if ($converted !== []) { + $return[$key] = $converted; + } + + continue; + } + + // For all other cases, use hasChanged() if (! $this->hasChanged($key)) { continue; } if ($recursive) { - if ($value instanceof self) { - $value = $value->toRawArray($onlyChanged, $recursive); - } elseif (is_callable([$value, 'toRawArray'])) { - $value = $value->toRawArray(); + // Special handling for arrays (mixed or not all entities) + if (is_array($value)) { + $converted = []; + + foreach ($value as $idx => $item) { + $converted[$idx] = $item instanceof self ? $item->toRawArray(false, true) : $convert($item); + } + $return[$key] = $converted; + + continue; } + + // default recursive conversion + $return[$key] = $convert($value); + + continue; } + // non-recursive changed value $return[$key] = $value; } @@ -347,6 +419,27 @@ public function hasChanged(?string $key = null): bool return $originalValue !== $currentValue; } + /** + * Checks if an array contains only Entity instances. + * This allows optimization for per-entity change tracking. + * + * @param array $data + */ + private function containsOnlyEntities(array $data): bool + { + if ($data === []) { + return false; + } + + foreach ($data as $item) { + if (! $item instanceof self) { + return false; + } + } + + return true; + } + /** * Recursively normalize a value for comparison. * Converts objects and arrays to a JSON-encodable format. @@ -365,7 +458,7 @@ private function normalizeValue(mixed $data): mixed if (is_object($data)) { // Check for Entity instance (use raw values, recursive) - if ($data instanceof Entity) { + if ($data instanceof self) { $objectData = $data->toRawArray(false, true); } elseif ($data instanceof JsonSerializable) { $objectData = $data->jsonSerialize(); diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 76fe1589ec08..42135c816ed9 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -1181,6 +1181,132 @@ public function testToRawArrayRecursive(): void ], $result); } + public function testToRawArrayRecursiveWithArray(): void + { + $entity = $this->getEntity(); + $entity->entities = [$this->getEntity(), $this->getEntity()]; + + $result = $entity->toRawArray(false, true); + + $this->assertSame([ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + 'entities' => [[ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ], [ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArray(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + + $entity = $this->getEntity(); + $entity->entities = [$first]; + $entity->syncOriginal(); + + $entity->entities = [$first, $second]; + + $result = $entity->toRawArray(true, true); + + $this->assertSame([ + 'entities' => [1 => [ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArrayEntityModified(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + $first->foo = 'original'; + $second->foo = 'also_original'; + + $entity = $this->getEntity(); + $entity->entities = [$first, $second]; + $entity->syncOriginal(); + + $second->foo = 'modified'; + + $result = $entity->toRawArray(true, true); + + $this->assertSame([ + 'entities' => [1 => [ + 'foo' => 'modified', + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArrayMultipleEntitiesModified(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + $third = $this->getEntity(); + $first->foo = 'first'; + $second->foo = 'second'; + $third->foo = 'third'; + + $entity = $this->getEntity(); + $entity->entities = [$first, $second, $third]; + $entity->syncOriginal(); + + $first->foo = 'first_modified'; + $third->foo = 'third_modified'; + + $result = $entity->toRawArray(true, true); + + $this->assertSame([ + 'entities' => [ + 0 => [ + 'foo' => 'first_modified', + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ], + 2 => [ + 'foo' => 'third_modified', + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ], + ], + ], $result); + } + + public function testToRawArrayRecursiveOnlyChangedWithArrayNoEntitiesModified(): void + { + $first = $this->getEntity(); + $second = $this->getEntity(); + $first->foo = 'unchanged'; + $second->foo = 'also_unchanged'; + + $entity = $this->getEntity(); + $entity->entities = [$first, $second]; + $entity->syncOriginal(); + + $result = $entity->toRawArray(true, true); + + $this->assertSame([], $result); + } + public function testToRawArrayOnlyChanged(): void { $entity = $this->getEntity(); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 5cfbbf6815f5..2778dc0f76fc 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -106,6 +106,11 @@ as a change because only reference comparison was performed. Now, any modificati state of objects or arrays will be properly detected. If you relied on the old shallow comparison behavior, you will need to update your code accordingly. +The ``Entity::toRawArray()`` method now properly converts arrays of entities when the ``$recursive`` +parameter is ``true``. Previously, properties containing arrays were not recursively processed. +If you were relying on the old behavior where arrays remained unconverted, you will need to update +your code. + Interface Changes ================= From e2fc5243b4dc8ac6e30eae7f177e1c3e744780b9 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sun, 28 Dec 2025 13:37:09 +0500 Subject: [PATCH 54/84] feat: Add support for HTTP status in `ResponseCache` (#9855) * feat: Added support for HTTP status in ResponseCache * Suggestions * Changelog --- system/Cache/ResponseCache.php | 11 ++++++++++- tests/system/Cache/ResponseCacheTest.php | 17 +++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/system/Cache/ResponseCache.php b/system/Cache/ResponseCache.php index 2c92dc5058be..7f0ea9e1da94 100644 --- a/system/Cache/ResponseCache.php +++ b/system/Cache/ResponseCache.php @@ -102,7 +102,12 @@ public function make(CLIRequest|IncomingRequest $request, ResponseInterface $res return $this->cache->save( $this->generateCacheKey($request), - serialize(['headers' => $headers, 'output' => $response->getBody()]), + serialize([ + 'headers' => $headers, + 'output' => $response->getBody(), + 'status' => $response->getStatusCode(), + 'reason' => $response->getReasonPhrase(), + ]), $this->ttl, ); } @@ -127,6 +132,8 @@ public function get(CLIRequest|IncomingRequest $request, ResponseInterface $resp $headers = $cachedResponse['headers']; $output = $cachedResponse['output']; + $status = $cachedResponse['status'] ?? 200; + $reason = $cachedResponse['reason'] ?? ''; // Clear all default headers foreach (array_keys($response->headers()) as $key) { @@ -140,6 +147,8 @@ public function get(CLIRequest|IncomingRequest $request, ResponseInterface $resp $response->setBody($output); + $response->setStatusCode($status, $reason); + return $response; } diff --git a/tests/system/Cache/ResponseCacheTest.php b/tests/system/Cache/ResponseCacheTest.php index b3f0ec832905..198caefbc015 100644 --- a/tests/system/Cache/ResponseCacheTest.php +++ b/tests/system/Cache/ResponseCacheTest.php @@ -107,6 +107,23 @@ public function testCachePageIncomingRequest(): void $this->assertNotInstanceOf(ResponseInterface::class, $cachedResponse); } + public function testCachePageIncomingRequestWithStatus(): void + { + $pageCache = $this->createResponseCache(); + + $response = new Response(new App()); + $response->setStatusCode(432, 'Foo Bar'); + $response->setBody('The response body.'); + + $this->assertTrue($pageCache->make($this->createIncomingRequest('foo/bar'), $response)); + + // Check cached response status + $cachedResponse = $pageCache->get($this->createIncomingRequest('foo/bar'), new Response(new App())); + $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); + $this->assertSame(432, $cachedResponse->getStatusCode()); + $this->assertSame('Foo Bar', $cachedResponse->getReasonPhrase()); + } + public function testCachePageIncomingRequestWithCacheQueryString(): void { $cache = new Cache(); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 2778dc0f76fc..ac950e8123ad 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -167,6 +167,7 @@ Libraries - **CLI:** Added ``SignalTrait`` to provide unified handling of operating system signals in CLI commands. - **Cache:** Added ``async`` and ``persistent`` config item to Predis handler. - **Cache:** Added ``persistent`` config item to Redis handler. +- **Cache:** Added support for HTTP status in ``ResponseCache``. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. From 0d52f5abb808e8bb9743cde0548720e7632c2979 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 30 Dec 2025 15:32:30 +0330 Subject: [PATCH 55/84] feat: prevent `Maximum call stack size exceeded` on client-managed requests (#9852) * feat: skip HTML/JS injection for partial requests * tests: add test for skip html/js injection in development mode * docs: Add explanation for skipping Debug Toolbar injection on AJAX-like requests * tests: try fix test * style: fix code style * refactor: remove unnecessary parts * feat: add MockCommon for shared test helpers and mocks * tests: refactor tests to use MockCommon helpers * fix: rector & cs * refactor: update test and other * refactor: internal cleanup and tooling alignment * tests: fix is_cli state * refactor: stop iterating headers after first match * fix: suppress RemoveExtraParametersRector false-positive for is_cli override * Update app/Config/Toolbar.php Co-authored-by: Michal Sniatala * Update system/Debug/Toolbar.php Co-authored-by: Michal Sniatala * refactor: improve by shouldDisableToolbar to check header values * test: fix test * refactor: simplify toolbar disable logic and remove unnecessary flags * docs: update method signature changes * refactor: add a backward-compatible fallback --------- Co-authored-by: Michal Sniatala --- app/Config/Toolbar.php | 25 +++++ rector.php | 6 ++ system/Boot.php | 7 ++ system/Debug/Toolbar.php | 45 +++++++-- tests/system/Debug/ToolbarTest.php | 102 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 6 ++ 6 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 tests/system/Debug/ToolbarTest.php diff --git a/app/Config/Toolbar.php b/app/Config/Toolbar.php index 5a3e5045d1e2..968653e9aaac 100644 --- a/app/Config/Toolbar.php +++ b/app/Config/Toolbar.php @@ -119,4 +119,29 @@ class Toolbar extends BaseConfig public array $watchedExtensions = [ 'php', 'css', 'js', 'html', 'svg', 'json', 'env', ]; + + /** + * -------------------------------------------------------------------------- + * Ignored HTTP Headers + * -------------------------------------------------------------------------- + * + * CodeIgniter Debug Toolbar normally injects HTML and JavaScript into every + * HTML response. This is correct for full page loads, but it breaks requests + * that expect only a clean HTML fragment. + * + * Libraries like HTMX, Unpoly, and Hotwire (Turbo) update parts of the page or + * manage navigation on the client side. Injecting the Debug Toolbar into their + * responses can cause invalid HTML, duplicated scripts, or JavaScript errors + * (such as infinite loops or "Maximum call stack size exceeded"). + * + * Any request containing one of the following headers is treated as a + * client-managed or partial request, and the Debug Toolbar injection is skipped. + * + * @var array + */ + public array $disableOnHeaders = [ + 'X-Requested-With' => 'xmlhttprequest', // AJAX requests + 'HX-Request' => 'true', // HTMX requests + 'X-Up-Version' => null, // Unpoly partial requests + ]; } diff --git a/rector.php b/rector.php index ef2e55a10116..6fbb1f67d738 100644 --- a/rector.php +++ b/rector.php @@ -31,6 +31,7 @@ use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector; use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector; use Rector\Php70\Rector\FuncCall\RandomFunctionRector; +use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector; use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector; @@ -107,6 +108,11 @@ __DIR__ . '/system/HTTP/Response.php', ], + // Exclude test file because `is_cli()` is mocked and Rector might remove needed parameters. + RemoveExtraParametersRector::class => [ + __DIR__ . '/tests/system/Debug/ToolbarTest.php', + ], + // check on constant compare UnwrapFutureCompatibleIfPhpVersionRector::class => [ __DIR__ . '/system/Autoloader/Autoloader.php', diff --git a/system/Boot.php b/system/Boot.php index 85b983c19d89..76f9fee8966d 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -144,7 +144,9 @@ public static function bootTest(Paths $paths): void static::loadDotEnv($paths); static::loadEnvironmentBootstrap($paths, false); + static::loadCommonFunctionsMock(); static::loadCommonFunctions(); + static::loadAutoloader(); static::setExceptionHandler(); static::initializeKint(); @@ -260,6 +262,11 @@ protected static function loadCommonFunctions(): void require_once SYSTEMPATH . 'Common.php'; } + protected static function loadCommonFunctionsMock(): void + { + require_once SYSTEMPATH . 'Test/Mock/MockCommon.php'; + } + /** * The autoloader allows all the pieces to work together in the framework. * We have to load it here, though, so that the config files can use the diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 7900c7c780c5..982e0db41b59 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -365,10 +365,8 @@ protected function roundTo(float $number, int $increments = 5): float /** * Prepare for debugging. - * - * @return void */ - public function prepare(?RequestInterface $request = null, ?ResponseInterface $response = null) + public function prepare(?RequestInterface $request = null, ?ResponseInterface $response = null): void { /** * @var IncomingRequest|null $request @@ -385,7 +383,7 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r return; } - $toolbar = service('toolbar', config(ToolbarConfig::class)); + $toolbar = service('toolbar', $this->config); $stats = $app->getPerformanceStats(); $data = $toolbar->run( $stats['startTime'], @@ -410,7 +408,7 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r // Non-HTML formats should not include the debugbar // then we send headers saying where to find the debug data // for this response - if ($request->isAJAX() || ! str_contains($format, 'html')) { + if ($this->shouldDisableToolbar($request) || ! str_contains($format, 'html')) { $response->setHeader('Debugbar-Time', "{$time}") ->setHeader('Debugbar-Link', site_url("?debugbar_time={$time}")); @@ -454,10 +452,8 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r * Inject debug toolbar into the response. * * @codeCoverageIgnore - * - * @return void */ - public function respond() + public function respond(): void { if (ENVIRONMENT === 'testing') { return; @@ -547,4 +543,37 @@ protected function format(string $data, string $format = 'html'): string return $output; } + + /** + * Determine if the toolbar should be disabled based on the request headers. + * + * This method allows checking both the presence of headers and their expected values. + * Useful for AJAX, HTMX, Unpoly, Turbo, etc., where partial HTML responses are expected. + * + * @return bool True if any header condition matches; false otherwise. + */ + private function shouldDisableToolbar(IncomingRequest $request): bool + { + // Fallback for older installations where the config option is missing (e.g. after upgrading from a previous version). + $headers = $this->config->disableOnHeaders ?? ['X-Requested-With' => 'xmlhttprequest']; + + foreach ($headers as $headerName => $expectedValue) { + if (! $request->hasHeader($headerName)) { + continue; // header not present, skip + } + + // If expectedValue is null, only presence is enough + if ($expectedValue === null) { + return true; + } + + $headerValue = strtolower($request->getHeaderLine($headerName)); + + if ($headerValue === strtolower($expectedValue)) { + return true; + } + } + + return false; + } } diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php new file mode 100644 index 000000000000..16dceb943536 --- /dev/null +++ b/tests/system/Debug/ToolbarTest.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\CodeIgniter; +use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Toolbar as ToolbarConfig; +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[BackupGlobals(true)] +#[Group('Others')] +final class ToolbarTest extends CIUnitTestCase +{ + private ToolbarConfig $config; + private ?IncomingRequest $request = null; + private ?ResponseInterface $response = null; + + protected function setUp(): void + { + parent::setUp(); + Services::reset(); + + is_cli(false); + + $this->config = new ToolbarConfig(); + + // Mock CodeIgniter core service to provide performance stats + $app = $this->createMock(CodeIgniter::class); + $app->method('getPerformanceStats')->willReturn([ + 'startTime' => microtime(true), + 'totalTime' => 0.05, + ]); + Services::injectMock('codeigniter', $app); + } + + protected function tearDown(): void + { + // Restore is_cli state + is_cli(true); + + parent::tearDown(); + } + + public function testPrepareRespectsDisableOnHeaders(): void + { + // Set up the new configuration property + $this->config->disableOnHeaders = ['HX-Request' => 'true']; + Factories::injectMock('config', 'Toolbar', $this->config); + + // Initialize Request with the custom header + $this->request = service('incomingrequest', null, false); + $this->request->setHeader('HX-Request', 'true'); + + // Initialize Response + $this->response = service('response', null, false); + $this->response->setBody('Content'); + $this->response->setHeader('Content-Type', 'text/html'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Assertions + $this->assertTrue($this->response->hasHeader('Debugbar-Time')); + $this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } + + public function testPrepareInjectsNormallyWithoutIgnoredHeader(): void + { + $this->config->disableOnHeaders = ['HX-Request' => 'true']; + Factories::injectMock('config', 'Toolbar', $this->config); + + $this->request = service('incomingrequest', null, false); + $this->response = service('response', null, false); + $this->response->setBody('Content'); + $this->response->setHeader('Content-Type', 'text/html'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Assertions + $this->assertStringContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index ac950e8123ad..e81fb40a03a3 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -141,6 +141,9 @@ Method Signature Changes - ``clean()`` - ``getCacheInfo()`` - ``getMetaData()`` +- Added native return types to ``CodeIgniter\Debug\Toolbar`` methods: + - ``prepare(): void`` + - ``respond(): void`` Removed Deprecated Items ======================== @@ -230,6 +233,8 @@ Changes - **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s T`` to follow the recommended format in RFC 7231. - **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. +- **Toolbar:** Added ``$disableOnHeaders`` property to **app/Config/Toolbar.php**. + ************ Deprecations @@ -245,6 +250,7 @@ Bugs Fixed - **Cookie:** The ``CookieInterface::SAMESITE_STRICT``, ``CookieInterface::SAMESITE_LAX``, and ``CookieInterface::SAMESITE_NONE`` constants are now written in ucfirst style to be consistent with usage in the rest of the framework. - **Cache:** Changed ``WincacheHandler::increment()`` and ``WincacheHandler::decrement()`` to return ``bool`` instead of ``mixed``. +- **Toolbar:** Fixed **Maximum call stack size exceeded** crash when AJAX-like requests (HTMX, Turbo, Unpoly, etc.) were made on pages with Debug Toolbar enabled. See the repo's `CHANGELOG.md `_ From 455068a227e0eab0d661223b1a9ac74e43677fe5 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 30 Dec 2025 16:40:38 +0100 Subject: [PATCH 56/84] feat: add configurable status code filtering for `PageCache` filter (#9856) --- app/Config/Cache.php | 24 +++ system/Filters/PageCache.php | 14 +- tests/system/Filters/PageCacheTest.php | 172 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 3 +- user_guide_src/source/general/caching.rst | 30 ++++ 5 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 tests/system/Filters/PageCacheTest.php diff --git a/app/Config/Cache.php b/app/Config/Cache.php index f2077c9d29b0..a8e3e1f053ff 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -169,4 +169,28 @@ class Cache extends BaseConfig * @var bool|list */ public $cacheQueryString = false; + + /** + * -------------------------------------------------------------------------- + * Web Page Caching: Cache Status Codes + * -------------------------------------------------------------------------- + * + * HTTP status codes that are allowed to be cached. Only responses with + * these status codes will be cached by the PageCache filter. + * + * Default: [] - Cache all status codes (backward compatible) + * + * Recommended: [200] - Only cache successful responses + * + * You can also use status codes like: + * [200, 404, 410] - Cache successful responses and specific error codes + * [200, 201, 202, 203, 204] - All 2xx successful responses + * + * WARNING: Using [] may cache temporary error pages (404, 500, etc). + * Consider restricting to [200] for production applications to avoid + * caching errors that should be temporary. + * + * @var list + */ + public array $cacheStatusCodes = []; } diff --git a/system/Filters/PageCache.php b/system/Filters/PageCache.php index 7a2949802723..c170e0d46cac 100644 --- a/system/Filters/PageCache.php +++ b/system/Filters/PageCache.php @@ -20,6 +20,7 @@ use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; +use Config\Cache; /** * Page Cache filter @@ -28,9 +29,17 @@ class PageCache implements FilterInterface { private readonly ResponseCache $pageCache; - public function __construct() + /** + * @var list + */ + private readonly array $cacheStatusCodes; + + public function __construct(?Cache $config = null) { - $this->pageCache = service('responsecache'); + $config ??= config('Cache'); + + $this->pageCache = service('responsecache'); + $this->cacheStatusCodes = $config->cacheStatusCodes ?? []; } /** @@ -61,6 +70,7 @@ public function after(RequestInterface $request, ResponseInterface $response, $a if ( ! $response instanceof DownloadResponse && ! $response instanceof RedirectResponse + && ($this->cacheStatusCodes === [] || in_array($response->getStatusCode(), $this->cacheStatusCodes, true)) ) { // Cache it without the performance metrics replaced // so that we can have live speed updates along the way. diff --git a/tests/system/Filters/PageCacheTest.php b/tests/system/Filters/PageCacheTest.php new file mode 100644 index 000000000000..baf46fc28cac --- /dev/null +++ b/tests/system/Filters/PageCacheTest.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\Response; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; +use Config\Cache; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class PageCacheTest extends CIUnitTestCase +{ + private function createRequest(): IncomingRequest + { + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_URI', '/'); + + $siteUri = new SiteURI(new App()); + + return new IncomingRequest(new App(), $siteUri, null, new UserAgent()); + } + + public function testDefaultConfigCachesAllStatusCodes(): void + { + $config = new Cache(); + $filter = new PageCache($config); + + $request = $this->createRequest(); + + $response200 = new Response(new App()); + $response200->setStatusCode(200); + $response200->setBody('Success'); + + $result = $filter->after($request, $response200); + $this->assertInstanceOf(Response::class, $result); + + $response404 = new Response(new App()); + $response404->setStatusCode(404); + $response404->setBody('Not Found'); + + $result = $filter->after($request, $response404); + $this->assertInstanceOf(Response::class, $result); + + $response500 = new Response(new App()); + $response500->setStatusCode(500); + $response500->setBody('Server Error'); + + $result = $filter->after($request, $response500); + $this->assertInstanceOf(Response::class, $result); + } + + public function testRestrictedConfigOnlyCaches200Responses(): void + { + $config = new Cache(); + $config->cacheStatusCodes = [200]; + $filter = new PageCache($config); + + $request = $this->createRequest(); + + // Test 200 response - should be cached + $response200 = new Response(new App()); + $response200->setStatusCode(200); + $response200->setBody('Success'); + + $result = $filter->after($request, $response200); + $this->assertInstanceOf(Response::class, $result); + + // Test 404 response - should NOT be cached + $response404 = new Response(new App()); + $response404->setStatusCode(404); + $response404->setBody('Not Found'); + + $result = $filter->after($request, $response404); + $this->assertNotInstanceOf(ResponseInterface::class, $result); + + // Test 500 response - should NOT be cached + $response500 = new Response(new App()); + $response500->setStatusCode(500); + $response500->setBody('Server Error'); + + $result = $filter->after($request, $response500); + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testCustomCacheStatusCodes(): void + { + $config = new Cache(); + $config->cacheStatusCodes = [200, 404, 410]; + $filter = new PageCache($config); + + $request = $this->createRequest(); + + $response200 = new Response(new App()); + $response200->setStatusCode(200); + $response200->setBody('Success'); + + $result = $filter->after($request, $response200); + $this->assertInstanceOf(Response::class, $result); + + $response404 = new Response(new App()); + $response404->setStatusCode(404); + $response404->setBody('Not Found'); + + $result = $filter->after($request, $response404); + $this->assertInstanceOf(Response::class, $result); + + $response410 = new Response(new App()); + $response410->setStatusCode(410); + $response410->setBody('Gone'); + + $result = $filter->after($request, $response410); + $this->assertInstanceOf(Response::class, $result); + + // Test 500 response - should NOT be cached (not in whitelist) + $response500 = new Response(new App()); + $response500->setStatusCode(500); + $response500->setBody('Server Error'); + + $result = $filter->after($request, $response500); + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testDownloadResponseNotCached(): void + { + $config = new Cache(); + $config->cacheStatusCodes = [200]; + $filter = new PageCache($config); + + $request = $this->createRequest(); + + $response = new DownloadResponse('test.txt', true); + + $result = $filter->after($request, $response); + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } + + public function testRedirectResponseNotCached(): void + { + $config = new Cache(); + $config->cacheStatusCodes = [200, 301, 302]; + $filter = new PageCache($config); + + $request = $this->createRequest(); + + $response = new RedirectResponse(new App()); + $response->redirect('/new-url'); + + $result = $filter->after($request, $response); + $this->assertNotInstanceOf(ResponseInterface::class, $result); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index e81fb40a03a3..2355e9a9f420 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -121,7 +121,7 @@ Method Signature Changes ======================== - **BaseModel:** The type of the ``$row`` parameter for the ``cleanValidationRules()`` method has been changed from ``?array $row = null`` to ``array $row``. - +- **PageCache:** The ``PageCache`` filter constructor now accepts an optional ``Cache`` configuration parameter: ``__construct(?Cache $config = null)``. This allows dependency injection for testing purposes. While this is technically a breaking change if you extend the ``PageCache`` class with your own constructor, it should not affect most users as the parameter has a default value. - Added the ``SensitiveParameter`` attribute to various methods to conceal sensitive information from stack traces. Affected methods are: - ``CodeIgniter\Encryption\EncrypterInterface::encrypt()`` - ``CodeIgniter\Encryption\EncrypterInterface::decrypt()`` @@ -171,6 +171,7 @@ Libraries - **Cache:** Added ``async`` and ``persistent`` config item to Predis handler. - **Cache:** Added ``persistent`` config item to Redis handler. - **Cache:** Added support for HTTP status in ``ResponseCache``. +- **Cache:** Added ``Config\Cache::$cacheStatusCodes`` to control which HTTP status codes are allowed to be cached by the ``PageCache`` filter. Defaults to ``[]`` (all status codes for backward compatibility). Recommended value: ``[200]`` to only cache successful responses. See :ref:`Setting $cacheStatusCodes ` for details. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. diff --git a/user_guide_src/source/general/caching.rst b/user_guide_src/source/general/caching.rst index 23856561b0c0..a5a92e29fe6d 100644 --- a/user_guide_src/source/general/caching.rst +++ b/user_guide_src/source/general/caching.rst @@ -63,6 +63,36 @@ Valid options are: - **array**: Enabled, but only take into account the specified list of query parameters. E.g., ``['q', 'page']``. +.. _web_page_caching_cache_status_codes: + +Setting $cacheStatusCodes +------------------------- + +.. versionadded:: 4.7.0 + +You can control which HTTP response status codes are allowed to be cached +with ``Config\Cache::$cacheStatusCodes``. + +Valid options are: + +- ``[]``: (default) Cache all HTTP status codes. This maintains backward + compatibility but may cache temporary error pages. +- ``[200]``: (Recommended) Only cache successful responses. This prevents + caching of error pages (404, 500, etc.) that should be temporary. +- array of status codes: Cache only specific status codes. For example: + + - ``[200, 404]``: Cache successful responses and not found pages. + - ``[200, 404, 410]``: Cache successful responses and specific error codes. + - ``[200, 201, 202, 203, 204]``: All 2xx successful responses. + +.. warning:: Using an empty array ``[]`` may cache temporary error pages (404, 500, etc). + For production applications, consider restricting this to ``[200]`` to avoid + caching errors that should be temporary. For example, a cached 404 page would + remain cached even after the resource is created, until the cache expires. + +.. note:: Regardless of this setting, ``DownloadResponse`` and ``RedirectResponse`` + instances are never cached by the ``PageCache`` filter. + Enabling Caching ================ From 772eaf49ef5e189833c9203d3d1898f7f994e4ea Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Thu, 1 Jan 2026 17:52:36 +0300 Subject: [PATCH 57/84] refactor: Session library (#9831) --- app/Config/Session.php | 1 + system/Language/en/Session.php | 2 +- system/Session/Handlers/ArrayHandler.php | 17 +- system/Session/Handlers/BaseHandler.php | 16 +- system/Session/Handlers/DatabaseHandler.php | 36 +- system/Session/Handlers/FileHandler.php | 26 +- system/Session/Handlers/MemcachedHandler.php | 28 +- system/Session/Handlers/RedisHandler.php | 36 +- system/Session/Session.php | 310 +----------------- system/Session/SessionInterface.php | 26 +- tests/system/Config/ServicesTest.php | 4 +- .../SecurityCSRFSessionRandomizeTokenTest.php | 6 + .../Security/SecurityCSRFSessionTest.php | 6 + .../Database/AbstractHandlerTestCase.php | 3 + .../Handlers/Database/RedisHandlerTest.php | 9 + tests/system/Session/SessionTest.php | 3 + user_guide_src/source/changelogs/v4.3.5.rst | 3 +- user_guide_src/source/changelogs/v4.7.0.rst | 14 +- .../source/installation/upgrade_435.rst | 2 - user_guide_src/source/libraries/sessions.rst | 44 +-- .../source/libraries/sessions/004.php | 2 +- .../source/libraries/sessions/005.php | 2 +- .../source/libraries/sessions/006.php | 2 +- .../source/libraries/sessions/007.php | 2 +- .../source/libraries/sessions/008.php | 11 - .../source/libraries/sessions/015.php | 25 +- .../source/libraries/sessions/039.php | 4 +- .../source/libraries/sessions/040.php | 2 +- .../source/libraries/sessions/041.php | 4 +- .../source/libraries/sessions/042.php | 4 +- .../source/libraries/sessions/043.php | 2 +- utils/phpstan-baseline/argument.type.neon | 7 +- utils/phpstan-baseline/empty.notAllowed.neon | 17 +- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 17 +- .../missingType.parameter.neon | 27 +- .../missingType.property.neon | 7 +- utils/phpstan-baseline/property.notFound.neon | 17 +- .../phpstan-baseline/property.phpDocType.neon | 2 +- .../staticMethod.notFound.neon | 12 +- .../ternary.shortNotAllowed.neon | 7 +- 41 files changed, 208 insertions(+), 559 deletions(-) delete mode 100644 user_guide_src/source/libraries/sessions/008.php diff --git a/app/Config/Session.php b/app/Config/Session.php index 6944710f705b..24912865f271 100644 --- a/app/Config/Session.php +++ b/app/Config/Session.php @@ -14,6 +14,7 @@ class Session extends BaseConfig * -------------------------------------------------------------------------- * * The session storage driver to use: + * - `CodeIgniter\Session\Handlers\ArrayHandler` (for testing) * - `CodeIgniter\Session\Handlers\FileHandler` * - `CodeIgniter\Session\Handlers\DatabaseHandler` * - `CodeIgniter\Session\Handlers\MemcachedHandler` diff --git a/system/Language/en/Session.php b/system/Language/en/Session.php index 340ef752c346..53d8bba95789 100644 --- a/system/Language/en/Session.php +++ b/system/Language/en/Session.php @@ -13,7 +13,7 @@ // Session language settings return [ - 'missingDatabaseTable' => '"sessionSavePath" must have the table name for the Database Session Handler to work.', + 'missingDatabaseTable' => 'Session: "savePath" must have the table name for the Database Session Handler to work.', 'invalidSavePath' => 'Session: Configured save path "{0}" is not a directory, does not exist or cannot be created.', 'writeProtectedSavePath' => 'Session: Configured save path "{0}" is not writable by the PHP process.', 'emptySavePath' => 'Session: No save path configured.', diff --git a/system/Session/Handlers/ArrayHandler.php b/system/Session/Handlers/ArrayHandler.php index 8faa00fdc9d7..0db48bb4b0c2 100644 --- a/system/Session/Handlers/ArrayHandler.php +++ b/system/Session/Handlers/ArrayHandler.php @@ -21,13 +21,16 @@ */ class ArrayHandler extends BaseHandler { + /** + * @var array + */ protected static $cache = []; /** * Re-initialize existing session, or creates a new one. * - * @param string $path The path where to store/retrieve the session - * @param string $name The session name + * @param string $path The path where to store/retrieve the session. + * @param string $name The session name. */ public function open($path, $name): bool { @@ -37,7 +40,7 @@ public function open($path, $name): bool /** * Reads the session data from the session storage, and returns the results. * - * @param string $id The session ID + * @param string $id The session ID. * * @return false|string Returns an encoded string of the read data. * If nothing was read, it must return false. @@ -51,8 +54,8 @@ public function read($id) /** * Writes the session data to the session storage. * - * @param string $id The session ID - * @param string $data The encoded session data + * @param string $id The session ID. + * @param string $data The encoded session data. */ public function write($id, $data): bool { @@ -68,9 +71,9 @@ public function close(): bool } /** - * Destroys a session + * Destroys a session. * - * @param string $id The session ID being destroyed + * @param string $id The session ID being destroyed. */ public function destroy($id): bool { diff --git a/system/Session/Handlers/BaseHandler.php b/system/Session/Handlers/BaseHandler.php index bc2cdc17af11..4c6a7ddb8211 100644 --- a/system/Session/Handlers/BaseHandler.php +++ b/system/Session/Handlers/BaseHandler.php @@ -19,7 +19,7 @@ use SessionHandlerInterface; /** - * Base class for session handling + * Base class for session handling. */ abstract class BaseHandler implements SessionHandlerInterface { @@ -40,7 +40,7 @@ abstract class BaseHandler implements SessionHandlerInterface protected $lock = false; /** - * Cookie prefix + * Cookie prefix. * * The Config\Cookie::$prefix setting is completely ignored. * See https://codeigniter.com/user_guide/libraries/sessions.html#session-preferences @@ -50,14 +50,14 @@ abstract class BaseHandler implements SessionHandlerInterface protected $cookiePrefix = ''; /** - * Cookie domain + * Cookie domain. * * @var string */ protected $cookieDomain = ''; /** - * Cookie path + * Cookie path. * * @var string */ @@ -71,7 +71,7 @@ abstract class BaseHandler implements SessionHandlerInterface protected $cookieSecure = false; /** - * Cookie name to use + * Cookie name to use. * * @var string */ @@ -85,7 +85,7 @@ abstract class BaseHandler implements SessionHandlerInterface protected $matchIP = false; /** - * Current session ID + * Current session ID. * * @var string|null */ @@ -93,9 +93,9 @@ abstract class BaseHandler implements SessionHandlerInterface /** * The 'save path' for the session - * varies between + * varies between. * - * @var array|string + * @var array|string */ protected $savePath; diff --git a/system/Session/Handlers/DatabaseHandler.php b/system/Session/Handlers/DatabaseHandler.php index 9fb5fcd89f95..e19986461384 100644 --- a/system/Session/Handlers/DatabaseHandler.php +++ b/system/Session/Handlers/DatabaseHandler.php @@ -21,7 +21,7 @@ use ReturnTypeWillChange; /** - * Base database session handler + * Base database session handler. * * Do not use this class. Use database specific handler class. */ @@ -49,21 +49,21 @@ class DatabaseHandler extends BaseHandler protected $db; /** - * The database type + * The database type. * * @var string */ protected $platform; /** - * Row exists flag + * Row exists flag. * * @var bool */ protected $rowExists = false; /** - * ID prefix for multiple session cookies + * ID prefix for multiple session cookies. */ protected string $idPrefix; @@ -74,16 +74,16 @@ public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); - // Store Session configurations - $this->DBGroup = $config->DBGroup ?? config(Database::class)->defaultGroup; - // Add sessionCookieName for multiple session cookies. - $this->idPrefix = $config->cookieName . ':'; - $this->table = $this->savePath; - if (empty($this->table)) { + + if ($this->table === '') { throw SessionException::forMissingDatabaseTable(); } + // Store Session configurations + $this->DBGroup = $config->DBGroup ?? config(Database::class)->defaultGroup; + // Add session cookie name for multiple session cookies. + $this->idPrefix = $config->cookieName . ':'; $this->db = Database::connect($this->DBGroup); $this->platform = $this->db->getPlatform(); } @@ -96,7 +96,7 @@ public function __construct(SessionConfig $config, string $ipAddress) */ public function open($path, $name): bool { - if (empty($this->db->connID)) { + if ($this->db->connID === false) { $this->db->initialize(); } @@ -153,7 +153,7 @@ public function read($id) } /** - * Sets SELECT clause + * Sets SELECT clause. * * @return void */ @@ -163,7 +163,7 @@ protected function setSelect(BaseBuilder $builder) } /** - * Decodes column data + * Decodes column data. * * @param string $data * @@ -177,8 +177,8 @@ protected function decodeData($data) /** * Writes the session data to the session storage. * - * @param string $id The session ID - * @param string $data The encoded session data + * @param string $id The session ID. + * @param string $data The encoded session data. */ public function write($id, $data): bool { @@ -230,7 +230,7 @@ public function write($id, $data): bool } /** - * Prepare data to insert/update + * Prepare data to insert/update. */ protected function prepareData(string $data): string { @@ -246,9 +246,9 @@ public function close(): bool } /** - * Destroys a session + * Destroys a session. * - * @param string $id The session ID being destroyed + * @param string $id The session ID being destroyed. */ public function destroy($id): bool { diff --git a/system/Session/Handlers/FileHandler.php b/system/Session/Handlers/FileHandler.php index 6938bcd2bbf6..3e400fb9af45 100644 --- a/system/Session/Handlers/FileHandler.php +++ b/system/Session/Handlers/FileHandler.php @@ -19,7 +19,7 @@ use ReturnTypeWillChange; /** - * Session handler using file system for storage + * Session handler using file system for storage. */ class FileHandler extends BaseHandler { @@ -31,14 +31,14 @@ class FileHandler extends BaseHandler protected $savePath; /** - * The file handle + * The file handle. * * @var resource|null */ protected $fileHandle; /** - * File Name + * File Name. * * @var string */ @@ -59,7 +59,7 @@ class FileHandler extends BaseHandler protected $matchIP = false; /** - * Regex of session ID + * Regex of session ID. * * @var string */ @@ -69,7 +69,7 @@ public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); - if (! empty($this->savePath)) { + if ($this->savePath !== '') { $this->savePath = rtrim($this->savePath, '/\\'); ini_set('session.save_path', $this->savePath); } else { @@ -88,8 +88,8 @@ public function __construct(SessionConfig $config, string $ipAddress) /** * Re-initialize existing session, or creates a new one. * - * @param string $path The path where to store/retrieve the session - * @param string $name The session name + * @param string $path The path where to store/retrieve the session. + * @param string $name The session name. * * @throws SessionException */ @@ -114,7 +114,7 @@ public function open($path, $name): bool /** * Reads the session data from the session storage, and returns the results. * - * @param string $id The session ID + * @param string $id The session ID. * * @return false|string Returns an encoded string of the read data. * If nothing was read, it must return false. @@ -175,8 +175,8 @@ public function read($id) /** * Writes the session data to the session storage. * - * @param string $id The session ID - * @param string $data The encoded session data + * @param string $id The session ID. + * @param string $data The encoded session data. */ public function write($id, $data): bool { @@ -239,9 +239,9 @@ public function close(): bool } /** - * Destroys a session + * Destroys a session. * - * @param string $id The session ID being destroyed + * @param string $id The session ID being destroyed. */ public function destroy($id): bool { @@ -310,7 +310,7 @@ public function gc($max_lifetime) } /** - * Configure Session ID regular expression + * Configure Session ID regular expression. * * To make life easier, we force the PHP defaults. Because PHP9 forces them. * diff --git a/system/Session/Handlers/MemcachedHandler.php b/system/Session/Handlers/MemcachedHandler.php index 744d9556ee02..90d02f6f394b 100644 --- a/system/Session/Handlers/MemcachedHandler.php +++ b/system/Session/Handlers/MemcachedHandler.php @@ -20,19 +20,19 @@ use ReturnTypeWillChange; /** - * Session handler using Memcache for persistence + * Session handler using Memcached for persistence. */ class MemcachedHandler extends BaseHandler { /** - * Memcached instance + * Memcached instance. * * @var Memcached|null */ protected $memcached; /** - * Key prefix + * Key prefix. * * @var string */ @@ -61,11 +61,11 @@ public function __construct(SessionConfig $config, string $ipAddress) $this->sessionExpiration = $config->expiration; - if (empty($this->savePath)) { + if ($this->savePath !== '') { throw SessionException::forEmptySavepath(); } - // Add sessionCookieName for multiple session cookies. + // Add session cookie name for multiple session cookies. $this->keyPrefix .= $config->cookieName . ':'; if ($this->matchIP === true) { @@ -78,8 +78,8 @@ public function __construct(SessionConfig $config, string $ipAddress) /** * Re-initialize existing session, or creates a new one. * - * @param string $path The path where to store/retrieve the session - * @param string $name The session name + * @param string $path The path where to store/retrieve the session. + * @param string $name The session name. */ public function open($path, $name): bool { @@ -137,7 +137,7 @@ public function open($path, $name): bool /** * Reads the session data from the session storage, and returns the results. * - * @param string $id The session ID + * @param string $id The session ID. * * @return false|string Returns an encoded string of the read data. * If nothing was read, it must return false. @@ -163,8 +163,8 @@ public function read($id) /** * Writes the session data to the session storage. * - * @param string $id The session ID - * @param string $data The encoded session data + * @param string $id The session ID. + * @param string $data The encoded session data. */ public function write($id, $data): bool { @@ -223,9 +223,9 @@ public function close(): bool } /** - * Destroys a session + * Destroys a session. * - * @param string $id The session ID being destroyed + * @param string $id The session ID being destroyed. */ public function destroy($id): bool { @@ -255,7 +255,7 @@ public function gc($max_lifetime) /** * Acquires an emulated lock. * - * @param string $sessionID Session ID + * @param string $sessionID Session ID. */ protected function lockSession(string $sessionID): bool { @@ -299,7 +299,7 @@ protected function lockSession(string $sessionID): bool } /** - * Releases a previously acquired lock + * Releases a previously acquired lock. */ protected function releaseLock(): bool { diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index eeb06043321b..27390174fb50 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -21,7 +21,7 @@ use ReturnTypeWillChange; /** - * Session handler using Redis for persistence + * Session handler using Redis for persistence. */ class RedisHandler extends BaseHandler { @@ -29,28 +29,28 @@ class RedisHandler extends BaseHandler private const DEFAULT_PROTOCOL = 'tcp'; /** - * phpRedis instance + * phpRedis instance. * * @var Redis|null */ protected $redis; /** - * Key prefix + * Key prefix. * * @var string */ protected $keyPrefix = 'ci_session:'; /** - * Lock key + * Lock key. * * @var string|null */ protected $lockKey; /** - * Key exists flag + * Key exists flag. * * @var bool */ @@ -74,7 +74,7 @@ class RedisHandler extends BaseHandler private int $lockMaxRetries = 300; /** - * @param string $ipAddress User's IP address + * @param string $ipAddress User's IP address. * * @throws SessionException */ @@ -82,12 +82,12 @@ public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); - // Store Session configurations + // Store Session configurations. $this->sessionExpiration = ($config->expiration === 0) ? (int) ini_get('session.gc_maxlifetime') : $config->expiration; - // Add sessionCookieName for multiple session cookies. + // Add session cookie name for multiple session cookies. $this->keyPrefix .= $config->cookieName . ':'; $this->setSavePath(); @@ -102,7 +102,7 @@ public function __construct(SessionConfig $config, string $ipAddress) protected function setSavePath(): void { - if (empty($this->savePath)) { + if ($this->savePath === '') { throw SessionException::forEmptySavepath(); } @@ -163,8 +163,8 @@ protected function setSavePath(): void /** * Re-initialize existing session, or creates a new one. * - * @param string $path The path where to store/retrieve the session - * @param string $name The session name + * @param string $path The path where to store/retrieve the session. + * @param string $name The session name. * * @throws RedisException */ @@ -202,7 +202,7 @@ public function open($path, $name): bool /** * Reads the session data from the session storage, and returns the results. * - * @param string $id The session ID + * @param string $id The session ID. * * @return false|string Returns an encoded string of the read data. * If nothing was read, it must return false. @@ -236,8 +236,8 @@ public function read($id) /** * Writes the session data to the session storage. * - * @param string $id The session ID - * @param string $data The encoded session data + * @param string $id The session ID. + * @param string $data The encoded session data. * * @throws RedisException */ @@ -307,9 +307,9 @@ public function close(): bool } /** - * Destroys a session + * Destroys a session. * - * @param string $id The session ID being destroyed + * @param string $id The session ID being destroyed. * * @throws RedisException */ @@ -345,7 +345,7 @@ public function gc($max_lifetime) /** * Acquires an emulated lock. * - * @param string $sessionID Session ID + * @param string $sessionID Session ID. * * @throws RedisException */ @@ -397,7 +397,7 @@ protected function lockSession(string $sessionID): bool } /** - * Releases a previously acquired lock + * Releases a previously acquired lock. * * @throws RedisException */ diff --git a/system/Session/Session.php b/system/Session/Session.php index f3dd570556c6..e3b435b3b8ff 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -24,9 +24,10 @@ * Implementation of CodeIgniter session container. * * Session configuration is done through session variables and cookie related - * variables in app/config/App.php + * variables in `Сonfig\Session`. * * @property string $session_id + * * @see \CodeIgniter\Session\SessionTest */ class Session implements SessionInterface @@ -40,84 +41,6 @@ class Session implements SessionInterface */ protected $driver; - /** - * The storage driver to use: files, database, redis, memcached - * - * @var string - * - * @deprecated Use $this->config->driver. - */ - protected $sessionDriverName; - - /** - * The session cookie name, must contain only [0-9a-z_-] characters. - * - * @var string - * - * @deprecated Use $this->config->cookieName. - */ - protected $sessionCookieName = 'ci_session'; - - /** - * The number of SECONDS you want the session to last. - * Setting it to 0 (zero) means expire when the browser is closed. - * - * @var int - * - * @deprecated Use $this->config->expiration. - */ - protected $sessionExpiration = 7200; - - /** - * The location to save sessions to, driver dependent. - * - * For the 'files' driver, it's a path to a writable directory. - * WARNING: Only absolute paths are supported! - * - * For the 'database' driver, it's a table name. - * - * @todo address memcache & redis needs - * - * IMPORTANT: You are REQUIRED to set a valid save path! - * - * @var string - * - * @deprecated Use $this->config->savePath. - */ - protected $sessionSavePath; - - /** - * Whether to match the user's IP address when reading the session data. - * - * WARNING: If you're using the database driver, don't forget to update - * your session table's PRIMARY KEY when changing this setting. - * - * @var bool - * - * @deprecated Use $this->config->matchIP. - */ - protected $sessionMatchIP = false; - - /** - * How many seconds between CI regenerating the session ID. - * - * @var int - * - * @deprecated Use $this->config->timeToUpdate. - */ - protected $sessionTimeToUpdate = 300; - - /** - * Whether to destroy session data associated with the old session ID - * when auto-regenerating the session ID. When set to FALSE, the data - * will be later deleted by the garbage collector. - * - * @var bool - * - * @deprecated Use $this->config->regenerateDestroy. - */ - protected $sessionRegenerateDestroy = false; - /** * The session cookie instance. * @@ -126,68 +49,22 @@ class Session implements SessionInterface protected $cookie; /** - * The domain name to use for cookies. - * Set to .your-domain.com for site-wide cookies. - * - * @var string - * - * @deprecated No longer used. - */ - protected $cookieDomain = ''; - - /** - * Path used for storing cookies. - * Typically will be a forward slash. - * - * @var string - * - * @deprecated No longer used. - */ - protected $cookiePath = '/'; - - /** - * Cookie will only be set if a secure HTTPS connection exists. - * - * @var bool - * - * @deprecated No longer used. - */ - protected $cookieSecure = false; - - /** - * Cookie SameSite setting as described in RFC6265 - * Must be 'None', 'Lax' or 'Strict'. - * - * @var string - * - * @deprecated No longer used. - */ - protected $cookieSameSite = Cookie::SAMESITE_LAX; - - /** - * sid regex expression + * Session ID regex expression. * * @var string */ protected $sidRegexp; - /** - * Session Config - */ protected SessionConfig $config; /** - * Constructor. - * * Extract configuration settings and save them here. */ public function __construct(SessionHandlerInterface $driver, SessionConfig $config) { $this->driver = $driver; - $this->config = $config; - - $cookie = config(CookieConfig::class); + $cookie = config(CookieConfig::class); $this->cookie = (new Cookie($this->config->cookieName, '', [ 'expires' => $this->config->expiration === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expiration, @@ -264,18 +141,6 @@ public function start() return $this; } - /** - * Destroys the current session. - * - * @deprecated Use destroy() instead. - * - * @return void - */ - public function stop() - { - $this->destroy(); - } - /** * Configuration. * @@ -287,7 +152,9 @@ protected function configure() { ini_set('session.name', $this->config->cookieName); - $sameSite = $this->cookie->getSameSite() ?: ucfirst(Cookie::SAMESITE_LAX); + $sameSite = $this->cookie->getSameSite() === '' + ? ucfirst(Cookie::SAMESITE_LAX) + : $this->cookie->getSameSite(); $params = [ 'lifetime' => $this->config->expiration, @@ -319,7 +186,7 @@ protected function configure() } /** - * Configure session ID length + * Configure session ID length. * * To make life easier, we force the PHP defaults. Because PHP9 forces them. * See https://wiki.php.net/rfc/deprecations_php_8_4#sessionsid_length_and_sessionsid_bits_per_character @@ -345,7 +212,7 @@ protected function configureSidLength() } /** - * Handle temporary variables + * Handle temporary variables. * * Clears old "flash" data, marks the new one for deletion and handles * "temp" data deletion. @@ -375,13 +242,6 @@ protected function initVars() } } - /** - * Regenerates the session ID. - * - * @param bool $destroy Should old session data be destroyed? - * - * @return void - */ public function regenerate(bool $destroy = false) { $_SESSION['__ci_last_regenerate'] = Time::now()->getTimestamp(); @@ -410,11 +270,6 @@ private function removeOldSessionCookie(): void } } - /** - * Destroys the current session. - * - * @return void - */ public function destroy() { if (ENVIRONMENT === 'testing') { @@ -438,20 +293,6 @@ public function close() session_write_close(); } - /** - * Sets user data into the session. - * - * If $data is a string, then it is interpreted as a session property - * key, and $value is expected to be non-null. - * - * If $data is an array, it is expected to be an array of key/value pairs - * to be set as session properties. - * - * @param array|list|string $data Property name or associative array of properties - * @param mixed $value Property value if single key provided - * - * @return void - */ public function set($data, $value = null) { $data = is_array($data) ? $data : [$data => $value]; @@ -465,19 +306,6 @@ public function set($data, $value = null) } } - /** - * Get user data that has been set in the session. - * - * If the property exists as "normal", returns it. - * Otherwise, returns an array of any temp or flash data values with the - * property key. - * - * Replaces the legacy method $session->userdata(); - * - * @param string|null $key Identifier of the session property to retrieve - * - * @return ($key is string ? mixed : array) - */ public function get(?string $key = null) { if (! isset($_SESSION) || $_SESSION === []) { @@ -502,11 +330,6 @@ public function get(?string $key = null) return $userdata; } - /** - * Returns whether an index exists in the session array. - * - * @param string $key Identifier of the session property we are interested in. - */ public function has(string $key): bool { return isset($_SESSION[$key]); @@ -527,17 +350,6 @@ public function push(string $key, array $data) } } - /** - * Remove one or more session properties. - * - * If $key is an array, it is interpreted as an array of string property - * identifiers to remove. Otherwise, it is interpreted as the identifier - * of a specific session property to remove. - * - * @param list|string $key Identifier of the session property or properties to remove. - * - * @return void - */ public function remove($key) { $key = is_array($key) ? $key : [$key]; @@ -549,10 +361,9 @@ public function remove($key) /** * Magic method to set variables in the session by simply calling - * $session->foo = bar; + * $session->foo = 'bar'; * - * @param string $key Identifier of the session property to set. - * @param mixed $value + * @param mixed $value * * @return void */ @@ -565,8 +376,6 @@ public function __set(string $key, $value) * Magic method to get session variables by simply calling * $foo = $session->foo; * - * @param string $key Identifier of the session property to remove. - * * @return mixed */ public function __get(string $key) @@ -589,43 +398,18 @@ public function __get(string $key) * * Different from `has()` in that it will validate 'session_id' as well. * Mostly used by internal PHP functions, users should stick to `has()`. - * - * @param string $key Identifier of the session property to remove. */ public function __isset(string $key): bool { return isset($_SESSION[$key]) || $key === 'session_id'; } - /** - * Sets data into the session that will only last for a single request. - * Perfect for use with single-use status update messages. - * - * If $data is an array, it is interpreted as an associative array of - * key/value pairs for flashdata properties. - * Otherwise, it is interpreted as the identifier of a specific - * flashdata property, with $value containing the property value. - * - * @param array|string $data Property identifier or associative array of properties - * @param mixed $value Property value if $data is a scalar - * - * @return void - */ public function setFlashdata($data, $value = null) { $this->set($data, $value); $this->markAsFlashdata(is_array($data) ? array_keys($data) : $data); } - /** - * Retrieve one or more items of flash data from the session. - * - * If the item key is null, return all flashdata. - * - * @param string|null $key Property identifier - * - * @return ($key is string ? mixed : array) - */ public function getFlashdata(?string $key = null) { $_SESSION['__ci_vars'] ??= []; @@ -649,24 +433,11 @@ public function getFlashdata(?string $key = null) return $flashdata; } - /** - * Keeps a single piece of flash data alive for one more request. - * - * @param list|string $key Property identifier or array of them - * - * @return void - */ public function keepFlashdata($key) { $this->markAsFlashdata($key); } - /** - * Mark a session property or properties as flashdata. This returns - * `false` if any of the properties were not already set. - * - * @param list|string $key Property identifier or array of them - */ public function markAsFlashdata($key): bool { $keys = is_array($key) ? $key : [$key]; @@ -683,13 +454,6 @@ public function markAsFlashdata($key): bool return true; } - /** - * Unmark data in the session as flashdata. - * - * @param list|string $key Property identifier or array of them - * - * @return void - */ public function unmarkFlashdata($key) { if (! isset($_SESSION['__ci_vars'])) { @@ -711,11 +475,6 @@ public function unmarkFlashdata($key) } } - /** - * Retrieve all of the keys for session data marked as flashdata. - * - * @return list - */ public function getFlashKeys(): array { if (! isset($_SESSION['__ci_vars'])) { @@ -733,30 +492,12 @@ public function getFlashKeys(): array return $keys; } - /** - * Sets new data into the session, and marks it as temporary data - * with a set lifespan. - * - * @param array|list|string $data Session data key or associative array of items - * @param mixed $value Value to store - * @param int $ttl Time-to-live in seconds - * - * @return void - */ public function setTempdata($data, $value = null, int $ttl = 300) { $this->set($data, $value); $this->markAsTempdata($data, $ttl); } - /** - * Returns either a single piece of tempdata, or all temp data currently - * in the session. - * - * @param string|null $key Session data key - * - * @return ($key is string ? mixed : array) - */ public function getTempdata(?string $key = null) { $_SESSION['__ci_vars'] ??= []; @@ -780,28 +521,12 @@ public function getTempdata(?string $key = null) return $tempdata; } - /** - * Removes a single piece of temporary data from the session. - * - * @param string $key Session data key - * - * @return void - */ public function removeTempdata(string $key) { $this->unmarkTempdata($key); unset($_SESSION[$key]); } - /** - * Mark one of more pieces of data as being temporary, meaning that - * it has a set lifespan within the session. - * - * Returns `false` if any of the properties were not set. - * - * @param array|list|string $key Property identifier or array of them - * @param int $ttl Time to live, in seconds - */ public function markAsTempdata($key, int $ttl = 300): bool { $time = Time::now()->getTimestamp(); @@ -833,14 +558,6 @@ public function markAsTempdata($key, int $ttl = 300): bool return true; } - /** - * Unmarks temporary data in the session, effectively removing its - * lifespan and allowing it to live as long as the session does. - * - * @param list|string $key Property identifier or array of them - * - * @return void - */ public function unmarkTempdata($key) { if (! isset($_SESSION['__ci_vars'])) { @@ -862,11 +579,6 @@ public function unmarkTempdata($key) } } - /** - * Retrieve the keys of all session data that have been marked as temporary data. - * - * @return list - */ public function getTempKeys(): array { if (! isset($_SESSION['__ci_vars'])) { diff --git a/system/Session/SessionInterface.php b/system/Session/SessionInterface.php index fc441c534494..bf4bc1a9fa7e 100644 --- a/system/Session/SessionInterface.php +++ b/system/Session/SessionInterface.php @@ -43,8 +43,8 @@ public function destroy(); * If $data is an array, it is expected to be an array of key/value pairs * to be set as session properties. * - * @param array|list|string $data Property name or associative array of properties - * @param mixed $value Property value if single key provided + * @param array|list|string $data Property name or associative array of properties. + * @param mixed $value Property value if single key provided. * * @return void */ @@ -59,7 +59,7 @@ public function set($data, $value = null); * * Replaces the legacy method $session->userdata(); * - * @param string|null $key Identifier of the session property to retrieve + * @param string|null $key Identifier of the session property to retrieve. * * @return ($key is string ? mixed : array) */ @@ -106,7 +106,7 @@ public function setFlashdata($data, $value = null); * * If the item key is null, return all flashdata. * - * @param string|null $key Property identifier + * @param string|null $key Property identifier. * * @return ($key is string ? mixed : array) */ @@ -115,7 +115,7 @@ public function getFlashdata(?string $key = null); /** * Keeps a single piece of flash data alive for one more request. * - * @param list|string $key Property identifier or array of them + * @param list|string $key Property identifier or array of them. * * @return void */ @@ -125,7 +125,7 @@ public function keepFlashdata($key); * Mark a session property or properties as flashdata. This returns * `false` if any of the properties were not already set. * - * @param list|string $key Property identifier or array of them + * @param list|string $key Property identifier or array of them. * * @return bool */ @@ -134,7 +134,7 @@ public function markAsFlashdata($key); /** * Unmark data in the session as flashdata. * - * @param list|string $key Property identifier or array of them + * @param list|string $key Property identifier or array of them. * * @return void */ @@ -151,9 +151,9 @@ public function getFlashKeys(): array; * Sets new data into the session, and marks it as temporary data * with a set lifespan. * - * @param array|list|string $data Session data key or associative array of items - * @param mixed $value Value to store - * @param int $ttl Time-to-live in seconds + * @param array|list|string $data Session data key or associative array of items. + * @param mixed $value Value to store. + * @param int $ttl Time-to-live in seconds. * * @return void */ @@ -163,7 +163,7 @@ public function setTempdata($data, $value = null, int $ttl = 300); * Returns either a single piece of tempdata, or all temp data currently * in the session. * - * @param string|null $key Session data key + * @param string|null $key Session data key. * * @return ($key is string ? mixed : array) */ @@ -172,7 +172,7 @@ public function getTempdata(?string $key = null); /** * Removes a single piece of temporary data from the session. * - * @param string $key Session data key + * @param string $key Session data key. * * @return void */ @@ -195,7 +195,7 @@ public function markAsTempdata($key, int $ttl = 300); * Unmarks temporary data in the session, effectively removing its * lifespan and allowing it to live as long as the session does. * - * @param list|string $key Property identifier or array of them + * @param list|string $key Property identifier or array of them. * * @return void */ diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index d7d55fc3d8b1..37b50890c400 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -313,10 +313,10 @@ public function testNewSessionWithInvalidDatabaseHandler(): void public function testCallStatic(): void { // __callStatic should kick in for this but fail - $actual = Services::SeSsIoNs(null, false); + $actual = Services::SeSsIoNs(null, false); // @phpstan-ignore staticMethod.notFound $this->assertNull($actual); // __callStatic should kick in for this - $actual = Services::SeSsIoN(null, false); + $actual = Services::SeSsIoN(null, false); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(Session::class, $actual); } diff --git a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php index 619f55829712..da9449c38ac2 100644 --- a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php @@ -297,6 +297,9 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $_POST['csrf_test_name'] = $this->randomizedToken; + /** + * @var SecurityConfig + */ $config = Factories::config('Security'); $config->tokenRandomize = true; $config->regenerate = false; @@ -317,6 +320,9 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $_POST['csrf_test_name'] = $this->randomizedToken; + /** + * @var SecurityConfig + */ $config = Factories::config('Security'); $config->tokenRandomize = true; $config->regenerate = true; diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php index 7fb618bef936..71177cb85ad3 100644 --- a/tests/system/Security/SecurityCSRFSessionTest.php +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -250,6 +250,9 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + /** + * @var SecurityConfig + */ $config = Factories::config('Security'); $config->regenerate = false; Factories::injectMock('config', 'Security', $config); @@ -269,6 +272,9 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + /** + * @var SecurityConfig + */ $config = Factories::config('Security'); $config->regenerate = true; Factories::injectMock('config', 'Security', $config); diff --git a/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php b/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php index 842824c4a76a..b5760d7f4f4e 100644 --- a/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php +++ b/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php @@ -44,6 +44,9 @@ protected function setUp(): void } } + /** + * @param array $options Replace values for `Config\Session`. + */ abstract protected function getInstance($options = []): DatabaseHandler; public function testOpen(): void diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 19efb98cd50d..7e38a81e97de 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -32,6 +32,9 @@ final class RedisHandlerTest extends CIUnitTestCase private string $sessionSavePath = 'tcp://127.0.0.1:6379'; private string $userIpAddress = '127.0.0.1'; + /** + * @param array $options Replace values for `Config\Session`. + */ protected function getInstance($options = []): RedisHandler { $defaults = [ @@ -137,6 +140,9 @@ public function testSecondaryReadAfterClose(): void $handler->close(); } + /** + * @param array $expected + */ #[DataProvider('provideSetSavePath')] public function testSetSavePath(string $savePath, array $expected): void { @@ -148,6 +154,9 @@ public function testSetSavePath(string $savePath, array $expected): void $this->assertSame($expected, $savePath); } + /** + * @return iterable|float|int|string|null>|string>> $expected + */ public static function provideSetSavePath(): iterable { yield from [ diff --git a/tests/system/Session/SessionTest.php b/tests/system/Session/SessionTest.php index 78aec295b718..dd3272405d8a 100644 --- a/tests/system/Session/SessionTest.php +++ b/tests/system/Session/SessionTest.php @@ -44,6 +44,9 @@ protected function setUp(): void $_SESSION = []; } + /** + * @param array $options Replace values for `Config\Session`. + */ protected function getInstance($options = []): MockSession { $defaults = [ diff --git a/user_guide_src/source/changelogs/v4.3.5.rst b/user_guide_src/source/changelogs/v4.3.5.rst index 0e5ab12a2fb1..9809357226ca 100644 --- a/user_guide_src/source/changelogs/v4.3.5.rst +++ b/user_guide_src/source/changelogs/v4.3.5.rst @@ -16,7 +16,6 @@ SECURITY See the `Security advisory GHSA-m6m8-6gq8-c9fj `_ for more information. - Fixed that ``Session::stop()`` did not destroy the session. - See :ref:`Session Library ` for details. Changes ******* @@ -30,7 +29,7 @@ Changes Deprecations ************ -- **Session:** The :ref:`Session::stop() ` method is deprecated. +- **Session:** The ``Session::stop()`` method is deprecated. Use the :ref:`Session::destroy() ` instead. Bugs Fixed diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 2355e9a9f420..16fc58a6abe2 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -149,14 +149,23 @@ Removed Deprecated Items ======================== - **BaseModel:** The deprecated method ``transformDataRowToArray()`` has been removed. -- **CodeIgniter:** The deprecated ``CodeIgniter\CodeIgniter::resolvePlatformExtensions()`` has been removed. - **Cache:** The deprecated return type ``false`` for ``CodeIgniter\Cache\CacheInterface::getMetaData()`` has been replaced with ``null`` type. +- **CodeIgniter:** The deprecated ``CodeIgniter\CodeIgniter::resolvePlatformExtensions()`` has been removed. - **IncomingRequest:** The deprecated methods has been removed: - ``CodeIgniter\HTTP\IncomingRequest\detectURI()`` - ``CodeIgniter\HTTP\IncomingRequest\detectPath()`` - ``CodeIgniter\HTTP\IncomingRequest\parseRequestURI()`` - ``CodeIgniter\HTTP\IncomingRequest\parseQueryString()`` - **IncomingRequest:** The deprecated ``$config`` parameter has been removed from ``CodeIgniter\HTTP\IncomingRequest::setPath()``, and the method visibility has been changed from ``public`` to ``private``. +- **Session:** The deprecated properties in ``CodeIgniter\Session\Session`` has been removed: + - ``CodeIgniter\Session\Session::$sessionDriverName`` + - ``CodeIgniter\Session\Session::$sessionCookieName`` + - ``CodeIgniter\Session\Session::$sessionExpiration`` + - ``CodeIgniter\Session\Session::$sessionSavePath`` + - ``CodeIgniter\Session\Session::$sessionMatchIP`` + - ``CodeIgniter\Session\Session::$sessionTimeToUpdate`` + - ``CodeIgniter\Session\Session::$sessionRegenerateDestroy`` +- **Session:** The deprecated method ``CodeIgniter\Session\Session::stop()`` has been removed. - **Text Helper:** The deprecated types in ``random_string()`` function: ``basic``, ``md5``, and ``sha1`` has been removed. ************ @@ -225,7 +234,8 @@ Message Changes *************** - Added ``Email.invalidSMTPAuthMethod``, ``Email.failureSMTPAuthMethod``, ``CLI.signals.noPcntlExtension``, ``CLI.signals.noPosixExtension`` and ``CLI.signals.failedSignal``. -- Deprecated ``Email.failedSMTPLogin`` and ``Image.libPathInvalid`` +- Deprecated ``Email.failedSMTPLogin`` and ``Image.libPathInvalid``. +- Changed ``Session.missingDatabaseTable``. ******* Changes diff --git a/user_guide_src/source/installation/upgrade_435.rst b/user_guide_src/source/installation/upgrade_435.rst index 347bb035befc..334cd3b52f08 100644 --- a/user_guide_src/source/installation/upgrade_435.rst +++ b/user_guide_src/source/installation/upgrade_435.rst @@ -43,8 +43,6 @@ because it is exactly the same as the ``Session::destroy()`` method. So use the If you have code to depend on the bug, replace it with ``session_regenerate_id(true)``. -See also :ref:`Session Library `. - Project Files ************* diff --git a/user_guide_src/source/libraries/sessions.rst b/user_guide_src/source/libraries/sessions.rst index a89e12df32c0..a42490f76552 100644 --- a/user_guide_src/source/libraries/sessions.rst +++ b/user_guide_src/source/libraries/sessions.rst @@ -29,9 +29,7 @@ The ``$config`` parameter is optional - your application configuration. If not provided, the services register will instantiate your default one. -Once loaded, the Sessions library object will be available using:: - - $session +Once loaded, the Sessions library object will be available using ``$session``. Alternatively, you can use the helper function that will use the default configuration options. This version is a little friendlier to read, @@ -118,7 +116,8 @@ Retrieving Session Data ======================= Any piece of information from the session array is available through the -``$_SESSION`` superglobal: +``$_SESSION`` superglobal. For example, to assign a previously stored ``name`` item to the ``$name`` +variable, you will do this: .. literalinclude:: sessions/004.php @@ -134,12 +133,6 @@ Or even through the session helper method: .. literalinclude:: sessions/007.php -Where ``item`` is the array key corresponding to the item you wish to fetch. -For example, to assign a previously stored ``name`` item to the ``$name`` -variable, you will do this: - -.. literalinclude:: sessions/008.php - .. note:: The ``get()`` method returns null if the item you are trying to access does not exist. @@ -191,7 +184,7 @@ Pushing New Value to Session Data ================================= The ``push()`` method is used to push a new value onto a session value that is an array. -For instance, if the ``hobbies`` key contains an array of hobbies, you can add a new value onto the array like so: +For instance, if the ``hobbies`` key contains an array of hobbies, you can add a new value or replace the previous value onto the array like so: .. literalinclude:: sessions/015.php @@ -397,21 +390,6 @@ All session data (including flashdata and tempdata) will be destroyed permanentl .. note:: You do not have to call this method from usual code. Cleanup session data rather than destroying the session. -.. _session-stop: - -stop() ------- - -.. deprecated:: 4.3.5 - -The session class also has the ``stop()`` method. - -.. warning:: Prior to v4.3.5, this method did not destroy the session due to a bug. - -Starting with v4.3.5, this method has been modified to destroy the session. -However, it is deprecated because it is exactly the same as the ``destroy()`` -method. Use the ``destroy()`` method instead. - Accessing Session Metadata ========================== @@ -453,7 +431,7 @@ Preference Default Opti **cookieName** ci_session [A-Za-z\_-] characters only The name used for the session cookie. **expiration** 7200 (2 hours) Time in seconds (integer) The number of seconds you would like the session to last. If you would like a non-expiring session (until browser is closed) set the value to zero: 0 -**savePath** null None Specifies the storage location, depends on the driver being used. +**savePath** WRITEPATH . 'session' None Specifies the storage location, depends on the driver being used. **matchIP** false true/false (boolean) Whether to validate the user's IP address when reading the session cookie. Note that some ISPs dynamically changes the IP, so if you want a non-expiring session you will likely set this to false. @@ -552,6 +530,10 @@ Instead, you should do something like this, depending on your environment: chmod 0700 //writable/sessions/ chown www-data //writable/sessions/ +The `built-in mechanism `_ for automatically clearing expired sessions may not run often, +and you will notice that the *saveDir* directory may be overflowing with files. +To solve this problem, you will need to configure the **cron** or **Task Scheduler** to delete outdated files. + Bonus Tip --------- @@ -608,10 +590,10 @@ And then of course, create the database table. For MySQL:: CREATE TABLE IF NOT EXISTS `ci_sessions` ( - `id` varchar(128) NOT null, - `ip_address` varchar(45) NOT null, - `timestamp` timestamp DEFAULT CURRENT_TIMESTAMP NOT null, - `data` blob NOT null, + `id` varchar(128) NOT NULL, + `ip_address` varchar(45) NOT NULL, + `timestamp` timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + `data` blob NOT NULL, KEY `ci_sessions_timestamp` (`timestamp`) ); diff --git a/user_guide_src/source/libraries/sessions/004.php b/user_guide_src/source/libraries/sessions/004.php index ec1e40879798..791a57f21ecf 100644 --- a/user_guide_src/source/libraries/sessions/004.php +++ b/user_guide_src/source/libraries/sessions/004.php @@ -1,3 +1,3 @@ get('item'); +$name = $session->get('name'); diff --git a/user_guide_src/source/libraries/sessions/006.php b/user_guide_src/source/libraries/sessions/006.php index 180d52833615..2e6c05c8e217 100644 --- a/user_guide_src/source/libraries/sessions/006.php +++ b/user_guide_src/source/libraries/sessions/006.php @@ -1,3 +1,3 @@ item; +$name = $session->name; diff --git a/user_guide_src/source/libraries/sessions/007.php b/user_guide_src/source/libraries/sessions/007.php index 2825b97d5c4b..852220f692a0 100644 --- a/user_guide_src/source/libraries/sessions/007.php +++ b/user_guide_src/source/libraries/sessions/007.php @@ -1,3 +1,3 @@ name; - -// or: - -$name = $session->get('name'); diff --git a/user_guide_src/source/libraries/sessions/015.php b/user_guide_src/source/libraries/sessions/015.php index e80a0177905a..c2ac72da85f8 100644 --- a/user_guide_src/source/libraries/sessions/015.php +++ b/user_guide_src/source/libraries/sessions/015.php @@ -1,3 +1,26 @@ push('hobbies', ['sport' => 'tennis']); +/** + * [hobbies] => Array + * ( + * [music] => rock + * [sport] => running + * ) + */ +$session->set('hobbies', [ + 'music' => 'rock', + 'sport' => 'running', +]); + +/** + * [hobbies] => Array + * ( + * [food] => cooking + * [music] => rock + * [sport] => tennis + * ) + */ +$session->push('hobbies', [ + 'food' => 'cooking', + 'sport' => 'tennis', +]); diff --git a/user_guide_src/source/libraries/sessions/039.php b/user_guide_src/source/libraries/sessions/039.php index a561e4a49cb6..45f2c4ce4272 100644 --- a/user_guide_src/source/libraries/sessions/039.php +++ b/user_guide_src/source/libraries/sessions/039.php @@ -3,12 +3,12 @@ namespace Config; use CodeIgniter\Config\BaseConfig; -use CodeIgniter\Session\Handlers\FileHandler; +use CodeIgniter\Session\Handlers\DatabaseHandler; class Session extends BaseConfig { // ... - public string $driver = 'CodeIgniter\Session\Handlers\DatabaseHandler'; + public string $driver = DatabaseHandler::class; // ... public string $savePath = 'ci_sessions'; diff --git a/user_guide_src/source/libraries/sessions/040.php b/user_guide_src/source/libraries/sessions/040.php index 386c83a0293b..86beb909fa20 100644 --- a/user_guide_src/source/libraries/sessions/040.php +++ b/user_guide_src/source/libraries/sessions/040.php @@ -3,7 +3,7 @@ namespace Config; use CodeIgniter\Config\BaseConfig; -use CodeIgniter\Session\Handlers\FileHandler; +use CodeIgniter\Session\Handlers\DatabaseHandler; class Session extends BaseConfig { diff --git a/user_guide_src/source/libraries/sessions/041.php b/user_guide_src/source/libraries/sessions/041.php index 091ec22ce651..765c79b83286 100644 --- a/user_guide_src/source/libraries/sessions/041.php +++ b/user_guide_src/source/libraries/sessions/041.php @@ -3,12 +3,12 @@ namespace Config; use CodeIgniter\Config\BaseConfig; -use CodeIgniter\Session\Handlers\FileHandler; +use CodeIgniter\Session\Handlers\RedisHandler; class Session extends BaseConfig { // ... - public string $driver = 'CodeIgniter\Session\Handlers\RedisHandler'; + public string $driver = RedisHandler::class; // ... public string $savePath = 'tcp://localhost:6379'; diff --git a/user_guide_src/source/libraries/sessions/042.php b/user_guide_src/source/libraries/sessions/042.php index 92ba42b9af3d..407b83e40fff 100644 --- a/user_guide_src/source/libraries/sessions/042.php +++ b/user_guide_src/source/libraries/sessions/042.php @@ -3,12 +3,12 @@ namespace Config; use CodeIgniter\Config\BaseConfig; -use CodeIgniter\Session\Handlers\FileHandler; +use CodeIgniter\Session\Handlers\MemcachedHandler; class Session extends BaseConfig { // ... - public string $driver = 'CodeIgniter\Session\Handlers\MemcachedHandler'; + public string $driver = MemcachedHandler::class; // ... public string $savePath = 'localhost:11211'; diff --git a/user_guide_src/source/libraries/sessions/043.php b/user_guide_src/source/libraries/sessions/043.php index f75e31430f93..83708fd4e0cd 100644 --- a/user_guide_src/source/libraries/sessions/043.php +++ b/user_guide_src/source/libraries/sessions/043.php @@ -3,7 +3,7 @@ namespace Config; use CodeIgniter\Config\BaseConfig; -use CodeIgniter\Session\Handlers\FileHandler; +use CodeIgniter\Session\Handlers\MemcachedHandler; class Session extends BaseConfig { diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index 91faa4168630..229b86cc1e8c 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,4 +1,4 @@ -# total 84 errors +# total 83 errors parameters: ignoreErrors: @@ -202,11 +202,6 @@ parameters: count: 1 path: ../../tests/system/RESTful/ResourceControllerTest.php - - - message: '#^Parameter \#1 \$config of class CodeIgniter\\Test\\Mock\\MockSecurity constructor expects Config\\Security, CodeIgniter\\Config\\BaseConfig\|null given\.$#' - count: 1 - path: ../../tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php - - message: '#^Parameter \#1 \$request of method CodeIgniter\\CodeIgniter\:\:setRequest\(\) expects CodeIgniter\\HTTP\\CLIRequest\|CodeIgniter\\HTTP\\IncomingRequest, CodeIgniter\\HTTP\\Request given\.$#' count: 1 diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index 65bd911eca30..b278eddd203a 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 228 errors +# total 223 errors parameters: ignoreErrors: @@ -292,24 +292,9 @@ parameters: count: 2 path: ../../system/Router/Router.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 2 - path: ../../system/Session/Handlers/DatabaseHandler.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 1 - path: ../../system/Session/Handlers/FileHandler.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' count: 1 - path: ../../system/Session/Handlers/MemcachedHandler.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 2 path: ../../system/Session/Handlers/RedisHandler.php - diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 3eb1f3bd917f..653c78dda08d 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2659 errors +# total 2635 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 36fa344cd39e..edeb2ece8387 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1317 errors +# total 1314 errors parameters: ignoreErrors: @@ -4212,11 +4212,6 @@ parameters: count: 1 path: ../../system/Router/RouterInterface.php - - - message: '#^Property CodeIgniter\\Session\\Handlers\\BaseHandler\:\:\$savePath type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Session/Handlers/BaseHandler.php - - message: '#^Method CodeIgniter\\Superglobals\:\:__construct\(\) has parameter \$get with no value type specified in iterable type array\.$#' count: 1 @@ -5902,16 +5897,6 @@ parameters: count: 1 path: ../../tests/system/Router/RouterTest.php - - - message: '#^Method CodeIgniter\\Session\\Handlers\\Database\\RedisHandlerTest\:\:provideSetSavePath\(\) return type has no value type specified in iterable type iterable\.$#' - count: 1 - path: ../../tests/system/Session/Handlers/Database/RedisHandlerTest.php - - - - message: '#^Method CodeIgniter\\Session\\Handlers\\Database\\RedisHandlerTest\:\:testSetSavePath\(\) has parameter \$expected with no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/Session/Handlers/Database/RedisHandlerTest.php - - message: '#^Method CodeIgniter\\Test\\ControllerTestTraitTest\:\:execute\(\) has parameter \$params with no value type specified in iterable type array\.$#' count: 1 diff --git a/utils/phpstan-baseline/missingType.parameter.neon b/utils/phpstan-baseline/missingType.parameter.neon index ec340a803f7c..3512e2769b9f 100644 --- a/utils/phpstan-baseline/missingType.parameter.neon +++ b/utils/phpstan-baseline/missingType.parameter.neon @@ -1,4 +1,4 @@ -# total 36 errors +# total 31 errors parameters: ignoreErrors: @@ -156,28 +156,3 @@ parameters: message: '#^Method CodeIgniter\\Security\\SecurityCSRFSessionTest\:\:createSession\(\) has parameter \$options with no type specified\.$#' count: 1 path: ../../tests/system/Security/SecurityCSRFSessionTest.php - - - - message: '#^Method CodeIgniter\\Session\\Handlers\\Database\\AbstractHandlerTestCase\:\:getInstance\(\) has parameter \$options with no type specified\.$#' - count: 1 - path: ../../tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php - - - - message: '#^Method CodeIgniter\\Session\\Handlers\\Database\\MySQLiHandlerTest\:\:getInstance\(\) has parameter \$options with no type specified\.$#' - count: 1 - path: ../../tests/system/Session/Handlers/Database/MySQLiHandlerTest.php - - - - message: '#^Method CodeIgniter\\Session\\Handlers\\Database\\PostgreHandlerTest\:\:getInstance\(\) has parameter \$options with no type specified\.$#' - count: 1 - path: ../../tests/system/Session/Handlers/Database/PostgreHandlerTest.php - - - - message: '#^Method CodeIgniter\\Session\\Handlers\\Database\\RedisHandlerTest\:\:getInstance\(\) has parameter \$options with no type specified\.$#' - count: 1 - path: ../../tests/system/Session/Handlers/Database/RedisHandlerTest.php - - - - message: '#^Method CodeIgniter\\Session\\SessionTest\:\:getInstance\(\) has parameter \$options with no type specified\.$#' - count: 1 - path: ../../tests/system/Session/SessionTest.php diff --git a/utils/phpstan-baseline/missingType.property.neon b/utils/phpstan-baseline/missingType.property.neon index 139386d26a14..fdf4057fee76 100644 --- a/utils/phpstan-baseline/missingType.property.neon +++ b/utils/phpstan-baseline/missingType.property.neon @@ -1,4 +1,4 @@ -# total 103 errors +# total 102 errors parameters: ignoreErrors: @@ -32,11 +32,6 @@ parameters: count: 1 path: ../../system/Database/Postgre/Connection.php - - - message: '#^Property CodeIgniter\\Session\\Handlers\\ArrayHandler\:\:\$cache has no type specified\.$#' - count: 1 - path: ../../system/Session/Handlers/ArrayHandler.php - - message: '#^Property CodeIgniter\\Config\\Factory@anonymous/tests/system/Config/FactoriesTest\.php\:89\:\:\$widgets has no type specified\.$#' count: 1 diff --git a/utils/phpstan-baseline/property.notFound.neon b/utils/phpstan-baseline/property.notFound.neon index 3d66f73b98bc..c522781b4f09 100644 --- a/utils/phpstan-baseline/property.notFound.neon +++ b/utils/phpstan-baseline/property.notFound.neon @@ -1,4 +1,4 @@ -# total 51 errors +# total 45 errors parameters: ignoreErrors: @@ -121,18 +121,3 @@ parameters: message: '#^Access to an undefined property CodeIgniter\\I18n\\Time\:\:\$weekOfWeek\.$#' count: 1 path: ../../tests/system/I18n/TimeTest.php - - - - message: '#^Access to an undefined property CodeIgniter\\Config\\BaseConfig\:\:\$regenerate\.$#' - count: 2 - path: ../../tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php - - - - message: '#^Access to an undefined property CodeIgniter\\Config\\BaseConfig\:\:\$tokenRandomize\.$#' - count: 2 - path: ../../tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php - - - - message: '#^Access to an undefined property CodeIgniter\\Config\\BaseConfig\:\:\$regenerate\.$#' - count: 2 - path: ../../tests/system/Security/SecurityCSRFSessionTest.php diff --git a/utils/phpstan-baseline/property.phpDocType.neon b/utils/phpstan-baseline/property.phpDocType.neon index 0a59dde8db0a..0ba558aded06 100644 --- a/utils/phpstan-baseline/property.phpDocType.neon +++ b/utils/phpstan-baseline/property.phpDocType.neon @@ -168,7 +168,7 @@ parameters: path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^PHPDoc type string of property CodeIgniter\\Session\\Handlers\\FileHandler\:\:\$savePath is not the same as PHPDoc type array\|string of overridden property CodeIgniter\\Session\\Handlers\\BaseHandler\:\:\$savePath\.$#' + message: '#^PHPDoc type string of property CodeIgniter\\Session\\Handlers\\FileHandler\:\:\$savePath is not the same as PHPDoc type array\\|string of overridden property CodeIgniter\\Session\\Handlers\\BaseHandler\:\:\$savePath\.$#' count: 1 path: ../../system/Session/Handlers/FileHandler.php diff --git a/utils/phpstan-baseline/staticMethod.notFound.neon b/utils/phpstan-baseline/staticMethod.notFound.neon index cec945e226cf..ad04f6d249a3 100644 --- a/utils/phpstan-baseline/staticMethod.notFound.neon +++ b/utils/phpstan-baseline/staticMethod.notFound.neon @@ -1,4 +1,4 @@ -# total 23 errors +# total 21 errors parameters: ignoreErrors: @@ -32,16 +32,6 @@ parameters: count: 13 path: ../../tests/system/Config/FactoriesTest.php - - - message: '#^Call to an undefined static method Tests\\Support\\Config\\Services\:\:SeSsIoN\(\)\.$#' - count: 1 - path: ../../tests/system/Config/ServicesTest.php - - - - message: '#^Call to an undefined static method Tests\\Support\\Config\\Services\:\:SeSsIoNs\(\)\.$#' - count: 1 - path: ../../tests/system/Config/ServicesTest.php - - message: '#^Call to an undefined static method Tests\\Support\\Config\\Services\:\:redirectResponse\(\)\.$#' count: 1 diff --git a/utils/phpstan-baseline/ternary.shortNotAllowed.neon b/utils/phpstan-baseline/ternary.shortNotAllowed.neon index d0ed19c4567e..0f831fde0a46 100644 --- a/utils/phpstan-baseline/ternary.shortNotAllowed.neon +++ b/utils/phpstan-baseline/ternary.shortNotAllowed.neon @@ -1,4 +1,4 @@ -# total 34 errors +# total 33 errors parameters: ignoreErrors: @@ -67,11 +67,6 @@ parameters: count: 1 path: ../../system/Router/AutoRouter.php - - - message: '#^Short ternary operator is not allowed\. Use null coalesce operator if applicable or consider using long ternary\.$#' - count: 1 - path: ../../system/Session/Session.php - - message: '#^Short ternary operator is not allowed\. Use null coalesce operator if applicable or consider using long ternary\.$#' count: 2 From ff20d8106d52413855f70fc58071d56ebbea17ff Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 1 Jan 2026 18:35:47 +0330 Subject: [PATCH 58/84] feat: add `isPast()` and `isFuture()` time convenience methods (#9861) --- system/I18n/TimeTrait.php | 20 ++++++++++++++ tests/system/I18n/TimeTest.php | 28 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/libraries/time.rst | 24 +++++++++++++++++ user_guide_src/source/libraries/time/043.php | 6 +++++ user_guide_src/source/libraries/time/044.php | 6 +++++ 6 files changed, 85 insertions(+) create mode 100644 user_guide_src/source/libraries/time/043.php create mode 100644 user_guide_src/source/libraries/time/044.php diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 2442f4b0c613..42cd1724b8c4 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -1028,6 +1028,26 @@ public function isAfter($testTime, ?string $timezone = null): bool return $ourTimestamp > $testTimestamp; } + /** + * Determines if the current instance's time is in the past. + * + * @throws Exception + */ + public function isPast(): bool + { + return $this->isBefore(static::now($this->timezone)); + } + + /** + * Determines if the current instance's time is in the future. + * + * @throws Exception + */ + public function isFuture(): bool + { + return $this->isAfter(static::now($this->timezone)); + } + // -------------------------------------------------------------------- // Differences // -------------------------------------------------------------------- diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index 4f76636ac7eb..9fb4b9226a85 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -1040,6 +1040,34 @@ public function testAfterWithMicroseconds(): void $this->assertFalse($time2->isAfter($time1)); } + public function testIsPast(): void + { + Time::setTestNow('2025-12-30 12:00:00', 'Asia/Tehran'); + + $past = Time::parse('2025-12-30 11:59:59', 'Asia/Tehran'); + $this->assertTrue($past->isPast()); + + $future = Time::parse('2025-12-30 12:00:01', 'Asia/Tehran'); + $this->assertFalse($future->isPast()); + + $now = Time::now('Asia/Tehran'); + $this->assertFalse($now->isPast()); + } + + public function testIsFuture(): void + { + Time::setTestNow('2025-12-30 12:00:00', 'Asia/Tehran'); + + $future = Time::parse('2025-12-30 12:00:01', 'Asia/Tehran'); + $this->assertTrue($future->isFuture()); + + $past = Time::parse('2025-12-30 11:59:59', 'Asia/Tehran'); + $this->assertFalse($past->isFuture()); + + $now = Time::now('Asia/Tehran'); + $this->assertFalse($now->isFuture()); + } + public function testHumanizeYearsSingle(): void { Time::setTestNow('March 10, 2017', 'America/Chicago'); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 16fc58a6abe2..19013de5cff8 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -190,6 +190,7 @@ Libraries - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` +- **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. Commands ======== diff --git a/user_guide_src/source/libraries/time.rst b/user_guide_src/source/libraries/time.rst index c3ddb3c16d39..53b869b1b12a 100644 --- a/user_guide_src/source/libraries/time.rst +++ b/user_guide_src/source/libraries/time.rst @@ -393,6 +393,30 @@ Works exactly the same as ``isBefore()`` except checks if the time is after the .. literalinclude:: time/037.php +.. _time-comparing-two-times-isPast: + +isPast() +-------- + +.. versionadded:: 4.7.0 + +Determines if the current instance's time is in the past, relative to "now". +It returns a boolean true/false:: + +.. literalinclude:: time/043.php + +.. _time-comparing-two-times-isFuture: + +isFuture() +---------- + +.. versionadded:: 4.7.0 + +Determines if the current instance's time is in the future, relative to "now". +It returns a boolean true/false:: + +.. literalinclude:: time/044.php + .. _time-viewing-differences: Viewing Differences diff --git a/user_guide_src/source/libraries/time/043.php b/user_guide_src/source/libraries/time/043.php new file mode 100644 index 000000000000..b256fff3ddce --- /dev/null +++ b/user_guide_src/source/libraries/time/043.php @@ -0,0 +1,6 @@ +isPast(); // true diff --git a/user_guide_src/source/libraries/time/044.php b/user_guide_src/source/libraries/time/044.php new file mode 100644 index 000000000000..d8cc511065c7 --- /dev/null +++ b/user_guide_src/source/libraries/time/044.php @@ -0,0 +1,6 @@ +isFuture(); // true From 2226f89351c99217875e2023e488e24b0946428e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sat, 3 Jan 2026 10:12:00 +0100 Subject: [PATCH 59/84] fix: resolve inconsistent PHPStan errors between local and CI environments (#9865) --- phpstan.neon.dist | 1 + utils/phpstan-baseline/arguments.count.neon | 8 ++++++++ utils/phpstan-baseline/loader.neon | 3 ++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 utils/phpstan-baseline/arguments.count.neon diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f0d8a8b691b8..a508f84c696a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -19,6 +19,7 @@ parameters: - system/Commands/Generators/Views/* - system/Debug/Toolbar/Views/toolbar.tpl.php - system/Images/Handlers/GDHandler.php + - system/Test/Mock/MockCommon.php - system/ThirdParty/* - system/Validation/Views/single.php - tests/system/View/Views/* diff --git a/utils/phpstan-baseline/arguments.count.neon b/utils/phpstan-baseline/arguments.count.neon new file mode 100644 index 000000000000..1df396472752 --- /dev/null +++ b/utils/phpstan-baseline/arguments.count.neon @@ -0,0 +1,8 @@ +# total 2 errors + +parameters: + ignoreErrors: + - + message: '#^Function is_cli invoked with 1 parameter, 0 required\.$#' + count: 2 + path: ../../tests/system/Debug/ToolbarTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 653c78dda08d..461f4d95e0b1 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,7 +1,8 @@ -# total 2635 errors +# total 2637 errors includes: - argument.type.neon + - arguments.count.neon - assign.propertyType.neon - booleanNot.exprNotBoolean.neon - codeigniter.getReassignArray.neon From 5f104f6cce3a3778f58f4a05778036e92475a58b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sun, 4 Jan 2026 17:44:50 +0330 Subject: [PATCH 60/84] feat: allow overriding namespaced views via `app/Views` directory (#9860) * feat: allow overriding namespaced views via app/Views * tests: add tests for namespaced view overrides * docs: document namespaced view overriding * docs: fix explicit markup ends without a blank line * refactor: optimize namespaced view path resolution Co-authored-by: Michal Sniatala * style: fix code style * Update system/View/View.php Co-authored-by: Michal Sniatala * Update user_guide_src/source/outgoing/views.rst Co-authored-by: Michal Sniatala * fix: conflicts resolved * tests: use ViewConfig alias instead of Config\View --------- Co-authored-by: Michal Sniatala --- app/Config/View.php | 17 +++++ system/View/View.php | 12 +++ tests/system/View/ViewTest.php | 83 +++++++++++++++++++-- user_guide_src/source/changelogs/v4.7.0.rst | 2 + user_guide_src/source/outgoing/views.rst | 47 ++++++++++++ 5 files changed, 156 insertions(+), 5 deletions(-) diff --git a/app/Config/View.php b/app/Config/View.php index cf8dd06f1065..582ef73276b1 100644 --- a/app/Config/View.php +++ b/app/Config/View.php @@ -59,4 +59,21 @@ class View extends BaseView * @var list> */ public array $decorators = []; + + /** + * Subdirectory within app/Views for namespaced view overrides. + * + * Namespaced views will be searched in: + * + * app/Views/{$appOverridesFolder}/{Namespace}/{view_path}.{php|html...} + * + * This allows application-level overrides for package or module views + * without modifying vendor source files. + * + * Examples: + * 'overrides' -> app/Views/overrides/Example/Blog/post/card.php + * 'vendor' -> app/Views/vendor/Example/Blog/post/card.php + * '' -> app/Views/Example/Blog/post/card.php (direct mapping) + */ + public string $appOverridesFolder = 'overrides'; } diff --git a/system/View/View.php b/system/View/View.php index 184f2528c32c..5e860853f44c 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -201,6 +201,18 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n $this->renderVars['file'] = $this->viewPath . $this->renderVars['view']; + if (str_contains($this->renderVars['view'], '\\')) { + $overrideFolder = $this->config->appOverridesFolder !== '' + ? trim($this->config->appOverridesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR + : ''; + + $this->renderVars['file'] = $this->viewPath + . $overrideFolder + . ltrim(str_replace('\\', DIRECTORY_SEPARATOR, $this->renderVars['view']), DIRECTORY_SEPARATOR); + } else { + $this->renderVars['file'] = $this->viewPath . $this->renderVars['view']; + } + if (! is_file($this->renderVars['file'])) { $this->renderVars['file'] = $this->loader->locateFile( $this->renderVars['view'], diff --git a/tests/system/View/ViewTest.php b/tests/system/View/ViewTest.php index b15e603408d8..d2dc5697ef54 100644 --- a/tests/system/View/ViewTest.php +++ b/tests/system/View/ViewTest.php @@ -17,7 +17,7 @@ use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\View\Exceptions\ViewException; -use Config; +use Config\View as ViewConfig; use PHPUnit\Framework\Attributes\Group; /** @@ -28,15 +28,16 @@ final class ViewTest extends CIUnitTestCase { private FileLocatorInterface $loader; private string $viewsDir; - private Config\View $config; + private ViewConfig $config; protected function setUp(): void { parent::setUp(); - $this->loader = service('locator'); - $this->viewsDir = __DIR__ . '/Views'; - $this->config = new Config\View(); + $this->loader = service('locator'); + $this->viewsDir = __DIR__ . '/Views'; + $this->config = new ViewConfig(); + $this->config->appOverridesFolder = ''; } public function testSetVarStoresData(): void @@ -413,4 +414,76 @@ public function testViewExcerpt(): void $this->assertSame('CodeIgniter is a PHP full-stack web framework...', $view->excerpt('CodeIgniter is a PHP full-stack web framework that is light, fast, flexible and secure.', 48)); $this->assertSame('CodeIgniter - это полнофункциональный веб-фреймворк...', $view->excerpt('CodeIgniter - это полнофункциональный веб-фреймворк на PHP, который является легким, быстрым, гибким и безопасным.', 54)); } + + public function testRenderNamespacedViewPriorityToAppViews(): void + { + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->never())->method('locateFile'); + + $view = new View($this->config, $this->viewsDir, $loader); + + $view->setVar('testString', 'Hello World'); + $expected = '

Hello World

'; + + $output = $view->render('Nested\simple'); + + $this->assertStringContainsString($expected, $output); + } + + public function testRenderNamespacedViewFallsBackToLoader(): void + { + $namespacedView = 'Some\Library\View'; + + $realFile = $this->viewsDir . '/simple.php'; + + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->once()) + ->method('locateFile') + ->with($namespacedView . '.php', 'Views', 'php') + ->willReturn($realFile); + + $view = new View($this->config, $this->viewsDir, $loader); + + $view->setVar('testString', 'Hello World'); + $output = $view->render($namespacedView); + + $this->assertStringContainsString('

Hello World

', $output); + } + + public function testRenderNamespacedViewWithExplicitExtension(): void + { + $namespacedView = 'Some\Library\View.html'; + + $realFile = $this->viewsDir . '/simple.php'; + + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->once()) + ->method('locateFile') + ->with($namespacedView, 'Views', 'html') + ->willReturn($realFile); + + $view = new View($this->config, $this->viewsDir, $loader); + $view->setVar('testString', 'Hello World'); + + $view->render($namespacedView); + } + + public function testOverrideWithCustomFolderChecksSubdirectory(): void + { + $this->config->appOverridesFolder = 'overrides'; + + $loader = $this->createMock(FileLocatorInterface::class); + $loader->expects($this->once()) + ->method('locateFile') + ->with('Nested\simple.php', 'Views', 'php') + ->willReturn($this->viewsDir . '/simple.php'); + + $view = new View($this->config, $this->viewsDir, $loader); + + $view->setVar('testString', 'Fallback Content'); + + $output = $view->render('Nested\simple'); + + $this->assertStringContainsString('

Fallback Content

', $output); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 19013de5cff8..0fabe898719c 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -191,6 +191,8 @@ Libraries - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` - **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. +- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. + Commands ======== diff --git a/user_guide_src/source/outgoing/views.rst b/user_guide_src/source/outgoing/views.rst index 2b73def3e485..2c6b3cbe1dc6 100644 --- a/user_guide_src/source/outgoing/views.rst +++ b/user_guide_src/source/outgoing/views.rst @@ -104,6 +104,53 @@ example, you could load the **blog_view.php** file from **example/blog/Views** b .. literalinclude:: views/005.php +.. _views-overriding-namespaced-views: + +Overriding Namespaced Views +=========================== + +.. versionadded:: 4.7.0 + +You can override a namespaced view by creating a matching directory structure within your application's **app/Views** directory. +This allows you to customize the output of modules or packages without modifying their core source code. + +Configuration +------------- + +By default, overrides are looked for in the **app/Views/overrides** directory. You can configure this location via the ``$appOverridesFolder`` property in **app/Config/View.php**: + +.. code-block:: php + + public string $appOverridesFolder = 'overrides'; + +If you prefer to map namespaces directly to the root of **app/Views** (without a subdirectory), you can set this value to an empty string (``''``). + +Example +------- + +Assume you have a module named **Blog** with the namespace ``Example\Blog``. The original view file is located at: + +.. code-block:: text + + /modules + └── Example + └── Blog + └── Views + └── blog_view.php + +To override this view (using the default configuration), create a file at the matching path within **app/Views/overrides**: + +.. code-block:: text + + /app + └── Views + └── overrides <-- Configured $appOverridesFolder + └── Example <-- Matches the first part of namespace + └── Blog <-- Matches the second part of namespace + └── blog_view.php <-- Your custom view + +Now, when you call ``view('Example\Blog\blog_view')``, CodeIgniter will automatically load your custom view from **app/Views/overrides/Example/Blog/blog_view.php** instead of the original module view file. + .. _caching-views: Caching Views From a46eeee5d2e591e3f00df7ca1426afa711fc809f Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Sun, 4 Jan 2026 19:14:58 +0100 Subject: [PATCH 61/84] fix: inconsistent `key` handling in encryption (#9868) --- system/Encryption/Handlers/OpenSSLHandler.php | 24 ++++---- system/Encryption/Handlers/SodiumHandler.php | 42 ++++++++++---- .../Handlers/OpenSSLHandlerTest.php | 23 ++++++++ .../Encryption/Handlers/SodiumHandlerTest.php | 42 ++++++++++++-- user_guide_src/source/changelogs/v4.7.0.rst | 57 +++++++++++++++++++ .../source/libraries/encryption.rst | 4 -- utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/property.notFound.neon | 6 +- 8 files changed, 162 insertions(+), 38 deletions(-) diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 9dca5b90304c..3df802c1b602 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -83,16 +83,16 @@ class OpenSSLHandler extends BaseHandler public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) { // Allow key override - if ($params !== null) { - $this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params; - } + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; - if (empty($this->key)) { + if (empty($key)) { throw EncryptionException::forNeedsStarterKey(); } // derive a secret key - $encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo); + $encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo); // basic encryption $iv = ($ivSize = \openssl_cipher_iv_length($this->cipher)) ? \openssl_random_pseudo_bytes($ivSize) : null; @@ -106,7 +106,7 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para $result = $this->rawData ? $iv . $data : base64_encode($iv . $data); // derive a secret key - $authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo); + $authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo); $hmacKey = \hash_hmac($this->digest, $result, $authKey, $this->rawData); @@ -119,16 +119,16 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para public function decrypt($data, #[SensitiveParameter] $params = null) { // Allow key override - if ($params !== null) { - $this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params; - } + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; - if (empty($this->key)) { + if (empty($key)) { throw EncryptionException::forNeedsStarterKey(); } // derive a secret key - $authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo); + $authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo); $hmacLength = $this->rawData ? $this->digestSize[$this->digest] @@ -152,7 +152,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) } // derive a secret key - $encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo); + $encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo); return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); } diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index 45f9ac2fa383..248436c28799 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -43,9 +43,17 @@ class SodiumHandler extends BaseHandler */ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) { - $this->parseParams($params); + // Allow key override + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; - if (empty($this->key)) { + // Allow blockSize override + $blockSize = (is_array($params) && isset($params['blockSize'])) + ? $params['blockSize'] + : $this->blockSize; + + if (empty($key)) { throw EncryptionException::forNeedsStarterKey(); } @@ -53,18 +61,18 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // 24 bytes // add padding before we encrypt the data - if ($this->blockSize <= 0) { + if ($blockSize <= 0) { throw EncryptionException::forEncryptionFailed(); } - $data = sodium_pad($data, $this->blockSize); + $data = sodium_pad($data, $blockSize); // encrypt message and combine with nonce - $ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $this->key); + $ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $key); // cleanup buffers sodium_memzero($data); - sodium_memzero($this->key); + sodium_memzero($key); return $ciphertext; } @@ -74,9 +82,17 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para */ public function decrypt($data, #[SensitiveParameter] $params = null) { - $this->parseParams($params); + // Allow key override + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; + + // Allow blockSize override + $blockSize = (is_array($params) && isset($params['blockSize'])) + ? $params['blockSize'] + : $this->blockSize; - if (empty($this->key)) { + if (empty($key)) { throw EncryptionException::forNeedsStarterKey(); } @@ -90,7 +106,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) $ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // decrypt data - $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key); + $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); if ($data === false) { // message was tampered in transit @@ -98,15 +114,15 @@ public function decrypt($data, #[SensitiveParameter] $params = null) } // remove extra padding during encryption - if ($this->blockSize <= 0) { + if ($blockSize <= 0) { throw EncryptionException::forAuthenticationFailed(); } - $data = sodium_unpad($data, $this->blockSize); + $data = sodium_unpad($data, $blockSize); // cleanup buffers sodium_memzero($ciphertext); - sodium_memzero($this->key); + sodium_memzero($key); return $data; } @@ -119,6 +135,8 @@ public function decrypt($data, #[SensitiveParameter] $params = null) * @return void * * @throws EncryptionException If key is empty + * + * @deprecated 4.7.0 No longer used. */ protected function parseParams($params) { diff --git a/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php b/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php index f82e9151cd3c..e883c5b2fbc6 100644 --- a/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php +++ b/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php @@ -137,4 +137,27 @@ public function testWithWrongKeyArray(): void $key2 = 'Holy cow, batman!'; $this->assertNotSame($message1, $encrypter->decrypt($encoded, ['key' => $key2])); } + + public function testInternalKeyNotModifiedByParams(): void + { + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = 'original-key-value'; + + $encrypter = $this->encryption->initialize($params); + + $this->assertSame('original-key-value', $encrypter->key); + + $message = 'This is a plain-text message.'; + $differentKey = 'temporary-param-key'; + $encoded = $encrypter->encrypt($message, ['key' => $differentKey]); + + $this->assertSame('original-key-value', $encrypter->key); + + $message2 = 'Another message.'; + $encoded2 = $encrypter->encrypt($message2); + $this->assertSame($message2, $encrypter->decrypt($encoded2)); + + $this->assertSame($message, $encrypter->decrypt($encoded, ['key' => $differentKey])); + } } diff --git a/tests/system/Encryption/Handlers/SodiumHandlerTest.php b/tests/system/Encryption/Handlers/SodiumHandlerTest.php index bf30036eb2a4..4e963762b997 100644 --- a/tests/system/Encryption/Handlers/SodiumHandlerTest.php +++ b/tests/system/Encryption/Handlers/SodiumHandlerTest.php @@ -78,14 +78,22 @@ public function testInvalidBlockSizeThrowsErrorOnEncrypt(): void $encrypter->encrypt('Some message.'); } - public function testEmptyKeyThrowsErrorOnDecrypt(): void + public function testHandlerCanBeReusedAfterEncryption(): void { - $this->expectException(EncryptionException::class); + $encrypter = $this->encryption->initialize($this->config); + $message = 'Some message to encrypt'; - $encrypter = $this->encryption->initialize($this->config); - $ciphertext = $encrypter->encrypt('Some message to encrypt'); - // After encrypt, the message and key are wiped from buffer - $encrypter->decrypt($ciphertext); + $ciphertext = $encrypter->encrypt($message); + $plaintext = $encrypter->decrypt($ciphertext); + + $this->assertSame($message, $plaintext); + + // Should also work for another encryption + $message2 = 'Another message'; + $ciphertext2 = $encrypter->encrypt($message2); + $plaintext2 = $encrypter->decrypt($ciphertext2); + + $this->assertSame($message2, $plaintext2); } public function testInvalidBlockSizeThrowsErrorOnDecrypt(): void @@ -121,4 +129,26 @@ public function testDecryptingMessages(): void $this->assertSame($msg, $encrypter->decrypt($ciphertext, $key)); $this->assertNotSame('A plain-text message for you.', $encrypter->decrypt($ciphertext, $key)); } + + public function testInternalKeyNotModifiedByParams(): void + { + $originalKey = sodium_crypto_secretbox_keygen(); + + $this->config->key = $originalKey; + $encrypter = $this->encryption->initialize($this->config); + + $this->assertSame($originalKey, $encrypter->key); + + $message = 'This is a plain-text message.'; + $differentKey = sodium_crypto_secretbox_keygen(); + $encoded = $encrypter->encrypt($message, ['key' => $differentKey]); + + $this->assertSame($originalKey, $encrypter->key); + + $message2 = 'Another message.'; + $encoded2 = $encrypter->encrypt($message2); + $this->assertSame($message2, $encrypter->decrypt($encoded2, ['key' => $originalKey])); + + $this->assertSame($message, $encrypter->decrypt($encoded, ['key' => $differentKey])); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 0fabe898719c..85d30224559d 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -111,6 +111,61 @@ parameter is ``true``. Previously, properties containing arrays were not recursi If you were relying on the old behavior where arrays remained unconverted, you will need to update your code. +Encryption Handlers +------------------- + +The ``OpenSSLHandler`` and ``SodiumHandler`` no longer modify the handler's ``$key`` property +when encryption/decryption parameters are passed via the ``$params`` argument. Keys passed through +``$params`` are now used as local variables, ensuring the handler's state remains unchanged. + +**What changed:** + +- Previously, passing a key via ``$params`` to ``encrypt()`` or ``decrypt()`` would permanently + modify the handler's internal ``$key`` property. +- Now, the handler's ``$key`` property is only set during handler creation via ``Config\Encryption``. + Passing keys through ``$params`` uses them as temporary local variables without modifying the handler's state. +- ``SodiumHandler::encrypt()`` no longer calls ``sodium_memzero($this->key)``, which previously + destroyed the encryption key after the first use, preventing handler reuse. + +**Impact:** + +**You are only affected if** you passed a key via ``$params`` to ``encrypt()`` or ``decrypt()`` +and expected that the ``key`` will persist for subsequent operations. Most users are **not affected**: + +- **Not affected:** You always pass the key via ``$params`` for each operation +- **Not affected:** You never use ``$params`` and always configure keys via ``Config\Encryption`` +- **Affected:** You passed a key via ``$params`` once and expected it to be remembered + +If affected, configure the key properly via ``Config\Encryption`` or pass a custom config to the +service instead of relying on ``$params`` side effects. + +**Example of affected code:** + +.. code-block:: php + + $config = config('Encryption'); + $config->key = 'your-encryption-key'; + $handler = service('encrypter', $config); + $handler->encrypt($data, 'temporary-key'); + // Old: $handler->key is now 'temporary-key' + // New: $handler->key remains unchanged ('your-encryption-key') + + $handler->encrypt($moreData); + // Old: Would use 'temporary-key' + // New: Uses default key ('your-encryption-key') + +**Migration:** + +To use a different encryption key permanently, pass a custom config when creating the service: + +.. code-block:: php + + $config = config('Encryption'); + $config->key = 'your-custom-encryption-key'; + + // Get a new handler instance with the custom config (not shared) + $handler = service('encrypter', $config, false); + Interface Changes ================= @@ -254,6 +309,8 @@ Changes Deprecations ************ +- **Encryption:** + - The method ``CodeIgniter\Encryption\Handlers\SodiumHandler::parseParams()`` has been deprecated. Parameters are now handled directly in ``encrypt()`` and ``decrypt()`` methods. - **Image:** - The config property ``Config\Image::libraryPath`` has been deprecated. No longer used. - The exception method ``CodeIgniter\Images\Exceptions\ImageException::forInvalidImageLibraryPath`` has been deprecated. No longer used. diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index 8c7a200d94eb..27192550ae1d 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -223,10 +223,6 @@ sending secret messages in an end-to-end scenario. To encrypt and/or authenticat a shared-key, such as symmetric encryption, Sodium uses the XSalsa20 algorithm to encrypt and HMAC-SHA512 for the authentication. -.. note:: CodeIgniter's ``SodiumHandler`` uses ``sodium_memzero`` in every encryption or decryption - session. After each session, the message (whether plaintext or ciphertext) and starter key are - wiped out from the buffers. You may need to provide again the key before starting a new session. - Message Length ============== diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 461f4d95e0b1..1c77572e3b3b 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2637 errors +# total 2639 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/property.notFound.neon b/utils/phpstan-baseline/property.notFound.neon index c522781b4f09..78a050cb7fb0 100644 --- a/utils/phpstan-baseline/property.notFound.neon +++ b/utils/phpstan-baseline/property.notFound.neon @@ -1,4 +1,4 @@ -# total 45 errors +# total 47 errors parameters: ignoreErrors: @@ -74,7 +74,7 @@ parameters: - message: '#^Access to an undefined property CodeIgniter\\Encryption\\EncrypterInterface\:\:\$key\.$#' - count: 2 + count: 3 path: ../../tests/system/Encryption/Handlers/OpenSSLHandlerTest.php - @@ -89,7 +89,7 @@ parameters: - message: '#^Access to an undefined property CodeIgniter\\Encryption\\EncrypterInterface\:\:\$key\.$#' - count: 1 + count: 2 path: ../../tests/system/Encryption/Handlers/SodiumHandlerTest.php - From e88e03a74342a71db83154516c0cc5c9d3a535cf Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 5 Jan 2026 15:18:59 +0330 Subject: [PATCH 62/84] feat: make DebugToolbar smarter about detecting binary/streamed responses (#9862) * feat: add native header detection to DebugToolbar * test: add infrastructure for mocking native header functions * test: add test for native header conflict detection * test: introduce NativeHeadersStack utility for native header testing * test: centralize native header function mocks * test: refactor existing tests to use NativeHeadersStack mocks * test: refactor NativeHeadersStack to use simplified header simulation * test: load mock once using setUpBeforeClass() * refactor: normalize native headers once for cleaner comparisons Co-authored-by: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> * refactor: improve readability of hasNativeHeaderConflict method * docs: update changelog for Toolbar native header detection fix --------- Co-authored-by: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> --- system/Debug/Toolbar.php | 30 +++++++ system/Test/Utilities/NativeHeadersStack.php | 64 +++++++++++++++ tests/_support/Mock/MockNativeHeaders.php | 42 ++++++++++ tests/system/Debug/ToolbarTest.php | 84 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + 5 files changed, 221 insertions(+) create mode 100644 system/Test/Utilities/NativeHeadersStack.php create mode 100644 tests/_support/Mock/MockNativeHeaders.php diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 982e0db41b59..7d828ad27d07 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -372,6 +372,10 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r * @var IncomingRequest|null $request */ if (CI_DEBUG && ! is_cli()) { + if ($this->hasNativeHeaderConflict()) { + return; + } + $app = service('codeigniter'); $request ??= service('request'); @@ -544,6 +548,32 @@ protected function format(string $data, string $format = 'html'): string return $output; } + /** + * Checks if the native PHP headers indicate a non-HTML response + * or if headers are already sent. + */ + protected function hasNativeHeaderConflict(): bool + { + // If headers are sent, we can't inject HTML. + if (headers_sent()) { + return true; + } + + // Native Header Inspection + foreach (headers_list() as $header) { + $lowerHeader = strtolower($header); + + $isNonHtmlContent = str_starts_with($lowerHeader, 'content-type:') && ! str_contains($lowerHeader, 'text/html'); + $isAttachment = str_starts_with($lowerHeader, 'content-disposition:') && str_contains($lowerHeader, 'attachment'); + + if ($isNonHtmlContent || $isAttachment) { + return true; + } + } + + return false; + } + /** * Determine if the toolbar should be disabled based on the request headers. * diff --git a/system/Test/Utilities/NativeHeadersStack.php b/system/Test/Utilities/NativeHeadersStack.php new file mode 100644 index 000000000000..be88b235836c --- /dev/null +++ b/system/Test/Utilities/NativeHeadersStack.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Utilities; + +/** + * A utility class for simulating native PHP header handling in unit tests. + * + * @internal This class is for testing purposes only. + */ +final class NativeHeadersStack +{ + /** + * Simulates whether headers have been sent. + */ + public static bool $headersSent = false; + + /** + * Stores the list of headers. + * + * @var list + */ + public static array $headers = []; + + /** + * Resets the header stack to defaults. + * Call this in setUp() to ensure clean state between tests. + */ + public static function reset(): void + { + self::$headersSent = false; + self::$headers = []; + } + + /** + * Checks if a specific header exists in the stack. + * + * @param string $header The exact header string (e.g., 'Content-Type: text/html') + */ + public static function has(string $header): bool + { + return in_array($header, self::$headers, true); + } + + /** + * Adds a header to the stack. + * + * @param string $header The header to add (e.g., 'Content-Type: text/html') + */ + public static function push(string $header): void + { + self::$headers[] = $header; + } +} diff --git a/tests/_support/Mock/MockNativeHeaders.php b/tests/_support/Mock/MockNativeHeaders.php new file mode 100644 index 000000000000..f901528661d4 --- /dev/null +++ b/tests/_support/Mock/MockNativeHeaders.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\Debug; + +use CodeIgniter\Test\Utilities\NativeHeadersStack; + +/** + * Mock implementation of the native PHP `headers_sent()` function. + * + * Instead of checking the actual PHP output buffer, this function + * checks the static property in NativeHeadersStack. + * + * @return bool True if headers are considered sent, false otherwise. + */ +function headers_sent(): bool +{ + return NativeHeadersStack::$headersSent; +} + +/** + * Mock implementation of the native PHP `headers_list()` function. + * + * Retrieves the array of headers stored in the NativeHeadersStack class + * rather than the actual headers sent by the server. + * + * @return array The list of simulated headers. + */ +function headers_list(): array +{ + return NativeHeadersStack::$headers; +} diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php index 16dceb943536..d701e7753ff7 100644 --- a/tests/system/Debug/ToolbarTest.php +++ b/tests/system/Debug/ToolbarTest.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Utilities\NativeHeadersStack; use Config\Toolbar as ToolbarConfig; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; @@ -34,9 +35,20 @@ final class ToolbarTest extends CIUnitTestCase private ?IncomingRequest $request = null; private ?ResponseInterface $response = null; + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + // Load the mock once for the whole test class + require_once SUPPORTPATH . 'Mock/MockNativeHeaders.php'; + } + protected function setUp(): void { parent::setUp(); + + NativeHeadersStack::reset(); + Services::reset(); is_cli(false); @@ -99,4 +111,76 @@ public function testPrepareInjectsNormallyWithoutIgnoredHeader(): void // Assertions $this->assertStringContainsString('id="debugbar_loader"', (string) $this->response->getBody()); } + + // ------------------------------------------------------------------------- + // Native Header Conflicts + // ------------------------------------------------------------------------- + + public function testPrepareAbortsIfHeadersAlreadySent(): void + { + // Headers explicitly sent (e.g., echo before execution) + NativeHeadersStack::$headersSent = true; + + $this->request = service('incomingrequest', null, false); + $this->response = service('response', null, false); + $this->response->setBody('Content'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Must NOT inject because we can't modify the body safely + $this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } + + public function testPrepareAbortsIfNativeContentTypeIsNotHtml(): void + { + // A library (like Dompdf) set a PDF header directly + NativeHeadersStack::push('Content-Type: application/pdf'); + + $this->request = service('incomingrequest', null, false); + $this->response = service('response', null, false); + // Even if the body looks like HTML (before rendering), the header says PDF + $this->response->setBody('Raw PDF Data'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Must NOT inject into non-HTML content + $this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } + + public function testPrepareAbortsIfNativeContentDispositionIsAttachment(): void + { + // A file download (even if it is HTML) + NativeHeadersStack::$headers = [ + 'Content-Type: text/html', + 'Content-Disposition: attachment; filename="report.html"', + ]; + + $this->request = service('incomingrequest', null, false); + $this->response = service('response', null, false); + $this->response->setBody('Downloadable Report'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Must NOT inject into downloads + $this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } + + public function testPrepareWorksWithNativeHtmlHeader(): void + { + // Standard scenario where PHP header is text/html + NativeHeadersStack::push('Content-Type: text/html; charset=UTF-8'); + + $this->request = service('incomingrequest', null, false); + $this->response = service('response', null, false); + $this->response->setBody('Valid Page'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Should inject normally + $this->assertStringContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 85d30224559d..f8e11e1a9608 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -247,6 +247,7 @@ Libraries - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` - **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. - **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. +- **Toolbar:** Fixed an issue where the Debug Toolbar was incorrectly injected into responses generated by third-party libraries (e.g., Dompdf) that use native PHP headers instead of the framework's Response object. Commands From f204ff735661a8dc292e0cb2d72355ab7d3f482f Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Mon, 5 Jan 2026 14:01:09 +0100 Subject: [PATCH 63/84] feat: complete `Superglobals` implementation (#9858) Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: neznaika0 --- system/Commands/Utilities/Environment.php | 7 +- system/Commands/Utilities/Routes.php | 7 +- system/Config/Services.php | 10 +- system/HTTP/DownloadResponse.php | 6 +- system/HTTP/Files/FileCollection.php | 6 +- system/HTTP/IncomingRequest.php | 12 +- system/HTTP/MessageTrait.php | 8 +- system/HTTP/RedirectResponse.php | 4 +- system/HTTP/RequestTrait.php | 33 +- system/HTTP/ResponseTrait.php | 15 +- system/Helpers/form_helper.php | 13 +- system/Log/Logger.php | 4 +- system/Pager/Pager.php | 9 +- system/Router/Router.php | 2 +- system/Security/Security.php | 7 +- system/Session/Session.php | 3 +- system/Superglobals.php | 396 +++++++- system/Test/FeatureTestTrait.php | 2 +- tests/system/API/TransformerTest.php | 2 + tests/system/CLI/CLITest.php | 27 +- tests/system/CLI/ConsoleTest.php | 12 +- tests/system/Cache/ResponseCacheTest.php | 11 +- tests/system/CodeIgniterTest.php | 364 +++---- .../Commands/EnvironmentCommandTest.php | 9 +- tests/system/Commands/GenerateKeyTest.php | 7 +- tests/system/CommonFunctionsTest.php | 38 +- tests/system/Config/BaseConfigTest.php | 9 +- tests/system/Config/DotEnvTest.php | 7 +- tests/system/Encryption/EncryptionTest.php | 7 +- tests/system/Filters/DebugToolbarTest.php | 6 +- tests/system/Filters/FiltersTest.php | 87 +- tests/system/Filters/HoneypotTest.php | 11 +- tests/system/Filters/InvalidCharsTest.php | 32 +- tests/system/HTTP/CLIRequestTest.php | 55 +- tests/system/HTTP/CURLRequestTest.php | 10 +- tests/system/HTTP/DownloadResponseTest.php | 12 +- .../system/HTTP/Files/FileCollectionTest.php | 148 +-- tests/system/HTTP/Files/FileMovingTest.php | 38 +- tests/system/HTTP/IncomingRequestTest.php | 158 +-- tests/system/HTTP/MessageTest.php | 37 +- tests/system/HTTP/RedirectResponseTest.php | 10 +- tests/system/HTTP/RequestTest.php | 57 +- tests/system/HTTP/ResponseTest.php | 37 +- .../SiteURIFactoryDetectRoutePathTest.php | 154 +-- tests/system/HTTP/SiteURIFactoryTest.php | 31 +- tests/system/HTTP/URITest.php | 30 +- tests/system/Helpers/CookieHelperTest.php | 11 +- tests/system/Helpers/FormHelperTest.php | 19 +- .../Helpers/URLHelper/CurrentUrlTest.php | 77 +- .../system/Helpers/URLHelper/MiscUrlTest.php | 9 +- .../system/Helpers/URLHelper/SiteUrlTest.php | 35 +- tests/system/Honeypot/HoneypotTest.php | 12 +- tests/system/Log/LoggerTest.php | 8 +- tests/system/Models/PaginateModelTest.php | 2 +- tests/system/Pager/PagerTest.php | 74 +- .../system/RESTful/ResourceControllerTest.php | 85 +- .../system/RESTful/ResourcePresenterTest.php | 85 +- tests/system/Router/RouteCollectionTest.php | 50 +- .../SecurityCSRFCookieRandomizeTokenTest.php | 16 +- .../SecurityCSRFSessionRandomizeTokenTest.php | 48 +- .../Security/SecurityCSRFSessionTest.php | 46 +- tests/system/Security/SecurityTest.php | 99 +- tests/system/Session/SessionTest.php | 13 +- tests/system/SuperglobalsTest.php | 524 +++++++++- tests/system/Test/FeatureTestTraitTest.php | 2 + .../Validation/StrictRules/FileRulesTest.php | 10 +- tests/system/Validation/ValidationTest.php | 25 +- utils/phpstan-baseline/argument.type.neon | 7 +- .../codeigniter.getReassignArray.neon | 44 +- .../codeigniter.superglobalAccess.neon | 118 +-- .../codeigniter.superglobalAccessAssign.neon | 907 +----------------- utils/phpstan-baseline/empty.notAllowed.neon | 4 +- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 72 +- 74 files changed, 2205 insertions(+), 2149 deletions(-) diff --git a/system/Commands/Utilities/Environment.php b/system/Commands/Utilities/Environment.php index 99a90415ceea..5ab4c98bde84 100644 --- a/system/Commands/Utilities/Environment.php +++ b/system/Commands/Utilities/Environment.php @@ -86,7 +86,7 @@ final class Environment extends BaseCommand public function run(array $params) { if ($params === []) { - CLI::write(sprintf('Your environment is currently set as %s.', CLI::color($_SERVER['CI_ENVIRONMENT'] ?? ENVIRONMENT, 'green'))); + CLI::write(sprintf('Your environment is currently set as %s.', CLI::color(service('superglobals')->server('CI_ENVIRONMENT', ENVIRONMENT), 'green'))); CLI::newLine(); return EXIT_ERROR; @@ -119,7 +119,8 @@ public function run(array $params) // force DotEnv to reload the new environment // however we cannot redefine the ENVIRONMENT constant putenv('CI_ENVIRONMENT'); - unset($_ENV['CI_ENVIRONMENT'], $_SERVER['CI_ENVIRONMENT']); + unset($_ENV['CI_ENVIRONMENT']); + service('superglobals')->unsetServer('CI_ENVIRONMENT'); (new DotEnv((new Paths())->envDirectory ?? ROOTPATH))->load(); CLI::write(sprintf('Environment is successfully changed to "%s".', $env), 'green'); @@ -149,7 +150,7 @@ private function writeNewEnvironmentToEnvFile(string $newEnv): bool copy($baseEnv, $envFile); } - $pattern = preg_quote($_SERVER['CI_ENVIRONMENT'] ?? ENVIRONMENT, '/'); + $pattern = preg_quote(service('superglobals')->server('CI_ENVIRONMENT', ENVIRONMENT), '/'); $pattern = sprintf('/^[#\s]*CI_ENVIRONMENT[=\s]+%s$/m', $pattern); return file_put_contents( diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php index 2a6122cab01c..4f51c3c29437 100644 --- a/system/Commands/Utilities/Routes.php +++ b/system/Commands/Utilities/Routes.php @@ -87,17 +87,14 @@ public function run(array $params) // Set HTTP_HOST if ($host !== null) { - $request = service('request'); - $_SERVER = $request->getServer(); - $_SERVER['HTTP_HOST'] = $host; - $request->setGlobal('server', $_SERVER); + service('superglobals')->setServer('HTTP_HOST', $host); } $collection = service('routes')->loadRoutes(); // Reset HTTP_HOST if ($host !== null) { - unset($_SERVER['HTTP_HOST']); + service('superglobals')->unsetServer('HTTP_HOST'); } $methods = Router::HTTP_METHODS; diff --git a/system/Config/Services.php b/system/Config/Services.php index c2a73f839f28..3878911c8cbf 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -543,7 +543,7 @@ public static function createRequest(App $config, bool $isCli = false): void $request = AppServices::incomingrequest($config); // guess at protocol if needed - $request->setProtocolVersion($_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'); + $request->setProtocolVersion(static::superglobals()->server('SERVER_PROTOCOL', 'HTTP/1.1')); } // Inject the request object into Services. @@ -746,13 +746,17 @@ public static function siteurifactory( public static function superglobals( ?array $server = null, ?array $get = null, + ?array $post = null, + ?array $cookie = null, + ?array $files = null, + ?array $request = null, bool $getShared = true, ) { if ($getShared) { - return static::getSharedInstance('superglobals', $server, $get); + return static::getSharedInstance('superglobals', $server, $get, $post, $cookie, $files, $request); } - return new Superglobals($server, $get); + return new Superglobals($server, $get, $post, $cookie, $files, $request); } /** diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index f6861d549e9f..5e1816e3afe1 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -171,9 +171,9 @@ private function getDownloadFileName(): string * * Reference: http://digiblog.de/2011/04/19/android-and-the-download-file-headers/ */ - // @todo: depend super global - if (count($x) !== 1 && isset($_SERVER['HTTP_USER_AGENT']) - && preg_match('/Android\s(1|2\.[01])/', $_SERVER['HTTP_USER_AGENT'])) { + $userAgent = service('superglobals')->server('HTTP_USER_AGENT'); + if (count($x) !== 1 && $userAgent !== null + && preg_match('/Android\s(1|2\.[01])/', $userAgent)) { $x[count($x) - 1] = strtoupper($extension); $filename = implode('.', $x); } diff --git a/system/HTTP/Files/FileCollection.php b/system/HTTP/Files/FileCollection.php index 484b3ff74872..5e3599b89853 100644 --- a/system/HTTP/Files/FileCollection.php +++ b/system/HTTP/Files/FileCollection.php @@ -150,11 +150,13 @@ protected function populateFiles() $this->files = []; - if ($_FILES === []) { + $files = service('superglobals')->getFilesArray(); + + if ($files === []) { return; } - $files = $this->fixFilesArray($_FILES); + $files = $this->fixFilesArray($files); foreach ($files as $name => $file) { $this->files[$name] = $this->createFileObject($file); diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 7fb5cd85bca7..f9a57a4098ba 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -266,7 +266,9 @@ public function isAJAX(): bool */ public function isSecure(): bool { - if (! empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') { + $https = service('superglobals')->server('HTTPS'); + + if ($https !== null && strtolower($https) !== 'off') { return true; } @@ -599,9 +601,9 @@ public function getPostGet($index = null, $filter = null, $flags = null) // Use $_POST directly here, since filter_has_var only // checks the initial POST data, not anything that might // have been added since. - return isset($_POST[$index]) + return service('superglobals')->post($index) !== null ? $this->getPost($index, $filter, $flags) - : (isset($_GET[$index]) ? $this->getGet($index, $filter, $flags) : $this->getPost($index, $filter, $flags)); + : (service('superglobals')->get($index) !== null ? $this->getGet($index, $filter, $flags) : $this->getPost($index, $filter, $flags)); } /** @@ -622,9 +624,9 @@ public function getGetPost($index = null, $filter = null, $flags = null) // Use $_GET directly here, since filter_has_var only // checks the initial GET data, not anything that might // have been added since. - return isset($_GET[$index]) + return service('superglobals')->get($index) !== null ? $this->getGet($index, $filter, $flags) - : (isset($_POST[$index]) ? $this->getPost($index, $filter, $flags) : $this->getGet($index, $filter, $flags)); + : (service('superglobals')->post($index) !== null ? $this->getPost($index, $filter, $flags) : $this->getGet($index, $filter, $flags)); } /** diff --git a/system/HTTP/MessageTrait.php b/system/HTTP/MessageTrait.php index 50daed9c0f8f..aed6987f34d7 100644 --- a/system/HTTP/MessageTrait.php +++ b/system/HTTP/MessageTrait.php @@ -86,19 +86,21 @@ public function appendBody($data): self */ public function populateHeaders(): void { - $contentType = $_SERVER['CONTENT_TYPE'] ?? getenv('CONTENT_TYPE'); + $contentType = service('superglobals')->server('CONTENT_TYPE', (string) getenv('CONTENT_TYPE')); if (! empty($contentType)) { $this->setHeader('Content-Type', $contentType); } unset($contentType); - foreach (array_keys($_SERVER) as $key) { + $serverArray = service('superglobals')->getServerArray(); + + foreach (array_keys($serverArray) as $key) { if (sscanf($key, 'HTTP_%s', $header) === 1) { // take SOME_HEADER and turn it into Some-Header $header = str_replace('_', ' ', strtolower($header)); $header = str_replace(' ', '-', ucwords($header)); - $this->setHeader($header, $_SERVER[$key]); + $this->setHeader($header, $serverArray[$key]); // Add us to the header map, so we can find them case-insensitively $this->headerMap[strtolower($header)] = $header; diff --git a/system/HTTP/RedirectResponse.php b/system/HTTP/RedirectResponse.php index 5703d1d6a78d..50c6b649373e 100644 --- a/system/HTTP/RedirectResponse.php +++ b/system/HTTP/RedirectResponse.php @@ -93,8 +93,8 @@ public function withInput() { $session = service('session'); $session->setFlashdata('_ci_old_input', [ - 'get' => $_GET ?? [], // @phpstan-ignore nullCoalesce.variable - 'post' => $_POST ?? [], // @phpstan-ignore nullCoalesce.variable + 'get' => service('superglobals')->getGetArray(), + 'post' => service('superglobals')->getPostArray(), ]); $this->withErrors(); diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index e0efd46324a1..6adfe3794935 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -47,6 +47,8 @@ trait RequestTrait * Stores values we've retrieved from PHP globals. * * @var array{get?: array, post?: array, request?: array, cookie?: array, server?: array} + * + * @deprecated 4.7.0 Use the Superglobals service instead */ protected $globals = []; @@ -231,8 +233,12 @@ public function getEnv($index = null, $filter = null, $flags = null) */ public function setGlobal(string $name, $value) { + // Keep BC with $globals array $this->globals[$name] = $value; + // Also update Superglobals via service + service('superglobals')->setGlobalArray($name, $value); + return $this; } @@ -342,6 +348,8 @@ public function fetchGlobal(string $name, $index = null, ?int $filter = null, $f * @param 'cookie'|'get'|'post'|'request'|'server' $name Superglobal name (lowercase) * * @return void + * + * @deprecated 4.7.0 No longer needs to be called explicitly. Used internally to maintain BC with $globals. */ protected function populateGlobals(string $name) { @@ -349,28 +357,7 @@ protected function populateGlobals(string $name) $this->globals[$name] = []; } - // Don't populate ENV as it might contain - // sensitive data that we don't want to get logged. - switch ($name) { - case 'get': - $this->globals['get'] = $_GET; - break; - - case 'post': - $this->globals['post'] = $_POST; - break; - - case 'request': - $this->globals['request'] = $_REQUEST; - break; - - case 'cookie': - $this->globals['cookie'] = $_COOKIE; - break; - - case 'server': - $this->globals['server'] = $_SERVER; - break; - } + // Get data from Superglobals service instead of direct access + $this->globals[$name] = service('superglobals')->getGlobalArray($name); } } diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index d39e8f9fdfaf..211663bc7987 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -451,21 +451,26 @@ public function sendBody() public function redirect(string $uri, string $method = 'auto', ?int $code = null) { // IIS environment likely? Use 'refresh' for better compatibility + $superglobals = service('superglobals'); + $serverSoftware = $superglobals->server('SERVER_SOFTWARE'); if ( $method === 'auto' - && isset($_SERVER['SERVER_SOFTWARE']) - && str_contains($_SERVER['SERVER_SOFTWARE'], 'Microsoft-IIS') + && $serverSoftware !== null + && str_contains($serverSoftware, 'Microsoft-IIS') ) { $method = 'refresh'; } elseif ($method !== 'refresh' && $code === null) { // override status code for HTTP/1.1 & higher + $serverProtocol = $superglobals->server('SERVER_PROTOCOL'); + $requestMethod = $superglobals->server('REQUEST_METHOD'); if ( - isset($_SERVER['SERVER_PROTOCOL'], $_SERVER['REQUEST_METHOD']) + $serverProtocol !== null + && $requestMethod !== null && $this->getProtocolVersion() >= 1.1 ) { - if ($_SERVER['REQUEST_METHOD'] === Method::GET) { + if ($requestMethod === Method::GET) { $code = 302; - } elseif (in_array($_SERVER['REQUEST_METHOD'], [Method::POST, Method::PUT, Method::DELETE], true)) { + } elseif (in_array($requestMethod, [Method::POST, Method::PUT, Method::DELETE], true)) { // reference: https://en.wikipedia.org/wiki/Post/Redirect/Get $code = 303; } else { diff --git a/system/Helpers/form_helper.php b/system/Helpers/form_helper.php index a9415821f970..6fa4777e2454 100644 --- a/system/Helpers/form_helper.php +++ b/system/Helpers/form_helper.php @@ -290,12 +290,17 @@ function form_dropdown($data = '', $options = [], $selected = [], $extra = ''): // If no selected state was submitted we will attempt to set it automatically if ($selected === []) { + $superglobals = service('superglobals'); if (is_array($data)) { - if (isset($data['name'], $_POST[$data['name']])) { - $selected = [$_POST[$data['name']]]; + $postValue = $superglobals->post($data['name'] ?? ''); + if (isset($data['name']) && $postValue !== null) { + $selected = [$postValue]; + } + } else { + $postValue = $superglobals->post($data); + if ($postValue !== null) { + $selected = [$postValue]; } - } elseif (isset($_POST[$data])) { - $selected = [$_POST[$data]]; } } diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 3770e3a6567f..8308ddaf94f7 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -310,8 +310,8 @@ protected function interpolate($message, array $context = []) $replace['{' . $key . '}'] = $val; } - $replace['{post_vars}'] = '$_POST: ' . print_r($_POST, true); - $replace['{get_vars}'] = '$_GET: ' . print_r($_GET, true); + $replace['{post_vars}'] = '$_POST: ' . print_r(service('superglobals')->getPostArray(), true); + $replace['{get_vars}'] = '$_GET: ' . print_r(service('superglobals')->getGetArray(), true); $replace['{env}'] = ENVIRONMENT; // Allow us to log the file/line that we are logging from diff --git a/system/Pager/Pager.php b/system/Pager/Pager.php index 6d0f54d14a00..e1c38edf3594 100644 --- a/system/Pager/Pager.php +++ b/system/Pager/Pager.php @@ -279,7 +279,7 @@ public function getPageURI(?int $page = null, string $group = 'default', bool $r } if ($this->only !== null) { - $query = array_intersect_key($_GET, array_flip($this->only)); + $query = array_intersect_key(service('superglobals')->getGetArray(), array_flip($this->only)); if (! $segment) { $query[$this->groups[$group]['pageSelector']] = $page; @@ -411,8 +411,9 @@ protected function ensureGroup(string $group, ?int $perPage = null) $this->calculateCurrentPage($group); - if ($_GET !== []) { - $this->groups[$group]['uri'] = $this->groups[$group]['uri']->setQueryArray($_GET); + $get = service('superglobals')->getGetArray(); + if ($get !== []) { + $this->groups[$group]['uri'] = $this->groups[$group]['uri']->setQueryArray($get); } } @@ -433,7 +434,7 @@ protected function calculateCurrentPage(string $group) } else { $pageSelector = $this->groups[$group]['pageSelector']; - $page = (int) ($_GET[$pageSelector] ?? 1); + $page = (int) (service('superglobals')->get($pageSelector, '1')); $this->groups[$group]['currentPage'] = $page < 1 ? 1 : $page; } diff --git a/system/Router/Router.php b/system/Router/Router.php index f2b291e58bbb..4c2ba230f6a0 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -167,7 +167,7 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request $this->controller = $this->collection->getDefaultController(); $this->method = $this->collection->getDefaultMethod(); - $this->collection->setHTTPVerb($request->getMethod() === '' ? $_SERVER['REQUEST_METHOD'] : $request->getMethod()); + $this->collection->setHTTPVerb($request->getMethod() === '' ? service('superglobals')->server('REQUEST_METHOD') : $request->getMethod()); $this->translateURIDashes = $this->collection->shouldTranslateURIDashes(); diff --git a/system/Security/Security.php b/system/Security/Security.php index c367d2d3b412..5e7d0bfeb679 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -281,10 +281,11 @@ private function removeTokenInRequest(RequestInterface $request): void { assert($request instanceof Request); - if (isset($_POST[$this->config->tokenName])) { + $superglobals = service('superglobals'); + if ($superglobals->post($this->config->tokenName) !== null) { // We kill this since we're done and we don't want to pollute the POST array. - unset($_POST[$this->config->tokenName]); - $request->setGlobal('post', $_POST); + $superglobals->unsetPost($this->config->tokenName); + $request->setGlobal('post', $superglobals->getPostArray()); } else { $body = $request->getBody() ?? ''; $json = json_decode($body); diff --git a/system/Session/Session.php b/system/Session/Session.php index e3b435b3b8ff..70a2e4a419dd 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -120,7 +120,8 @@ public function start() $this->startSession(); // Is session ID auto-regeneration configured? (ignoring ajax requests) - if ((! isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') + $requestedWith = service('superglobals')->server('HTTP_X_REQUESTED_WITH'); + if (($requestedWith === null || strtolower($requestedWith) !== 'xmlhttprequest') && ($regenerateTime = $this->config->timeToUpdate) > 0 ) { if (! isset($_SESSION['__ci_last_regenerate'])) { diff --git a/system/Superglobals.php b/system/Superglobals.php index d804de2bc541..4acfe2c0d3ab 100644 --- a/system/Superglobals.php +++ b/system/Superglobals.php @@ -13,51 +13,417 @@ namespace CodeIgniter; +use CodeIgniter\Exceptions\InvalidArgumentException; + /** * Superglobals manipulation. * + * Provides a clean API for accessing and manipulating PHP superglobals + * with support for testing and backward compatibility. + * + * Note on return types: + * - $_SERVER can contain int (argc, REQUEST_TIME) or float (REQUEST_TIME_FLOAT) + * - $_SERVER['argv'] is an array + * - $_GET, $_POST, $_REQUEST can contain nested arrays from query params like ?foo[bar]=value + * - $_COOKIE typically contains strings but can have arrays with cookie[key] notation + * + * Note: $_FILES only supports array operations (getFilesArray/setFilesArray). + * Individual key operations are not provided as files don't change after request initialization. + * + * @phpstan-type server_items array|float|int|string + * @phpstan-type get_items array|string + * @phpstan-type post_items array|string + * @phpstan-type cookie_items array|string + * @phpstan-type files_items array + * @phpstan-type request_items array|string + * * @internal * @see \CodeIgniter\SuperglobalsTest */ final class Superglobals { - private array $server; - private array $get; - - public function __construct(?array $server = null, ?array $get = null) - { - $this->server = $server ?? $_SERVER; - $this->get = $get ?? $_GET; + /** + * @param array|null $server + * @param array|null $get + * @param array|null $post + * @param array|null $cookie + * @param array|null $files + * @param array|null $request + */ + public function __construct( + private ?array $server = null, + private ?array $get = null, + private ?array $post = null, + private ?array $cookie = null, + private ?array $files = null, + private ?array $request = null, + ) { + $this + ->setServerArray($server ?? $_SERVER) + ->setGetArray($get ?? $_GET) + ->setPostArray($post ?? $_POST) + ->setCookieArray($cookie ?? $_COOKIE) + ->setFilesArray($files ?? $_FILES) + ->setRequestArray($request ?? $_REQUEST); } - public function server(string $key): ?string + /** + * Get a value from $_SERVER. + * + * @param server_items|null $default + * + * @return server_items|null + */ + public function server(string $key, mixed $default = null): array|float|int|string|null { - return $this->server[$key] ?? null; + return $this->server[$key] ?? $default; } - public function setServer(string $key, string $value): void + /** + * Set a value in $_SERVER. + * + * @param server_items $value + */ + public function setServer(string $key, array|float|int|string $value): self { $this->server[$key] = $value; $_SERVER[$key] = $value; + + return $this; } /** - * @return array|string|null + * Remove a key from $_SERVER. */ - public function get(string $key) + public function unsetServer(string $key): self { - return $this->get[$key] ?? null; + unset($this->server[$key], $_SERVER[$key]); + + return $this; } - public function setGet(string $key, string $value): void + /** + * Get all $_SERVER values. + * + * @return array + */ + public function getServerArray(): array + { + return $this->server; + } + + /** + * Set the entire $_SERVER array. + * + * @param array $array + */ + public function setServerArray(array $array): self + { + $this->server = $array; + $_SERVER = $array; + + return $this; + } + + /** + * Get a value from $_GET. + * + * @param get_items|null $default + * + * @return get_items|null + */ + public function get(string $key, mixed $default = null): array|string|null + { + return $this->get[$key] ?? $default; + } + + /** + * Set a value in $_GET. + * + * @param get_items $value + */ + public function setGet(string $key, array|string $value): self { $this->get[$key] = $value; $_GET[$key] = $value; + + return $this; } - public function setGetArray(array $array): void + /** + * Remove a key from $_GET. + */ + public function unsetGet(string $key): self + { + unset($this->get[$key], $_GET[$key]); + + return $this; + } + + /** + * Get all $_GET values. + * + * @return array + */ + public function getGetArray(): array + { + return $this->get; + } + + /** + * Set the entire $_GET array. + * + * @param array $array + */ + public function setGetArray(array $array): self { $this->get = $array; $_GET = $array; + + return $this; + } + + /** + * Get a value from $_POST. + * + * @param post_items|null $default + * + * @return post_items|null + */ + public function post(string $key, mixed $default = null): array|string|null + { + return $this->post[$key] ?? $default; + } + + /** + * Set a value in $_POST. + * + * @param post_items $value + */ + public function setPost(string $key, array|string $value): self + { + $this->post[$key] = $value; + $_POST[$key] = $value; + + return $this; + } + + /** + * Remove a key from $_POST. + */ + public function unsetPost(string $key): self + { + unset($this->post[$key], $_POST[$key]); + + return $this; + } + + /** + * Get all $_POST values. + * + * @return array + */ + public function getPostArray(): array + { + return $this->post; + } + + /** + * Set the entire $_POST array. + * + * @param array $array + */ + public function setPostArray(array $array): self + { + $this->post = $array; + $_POST = $array; + + return $this; + } + + /** + * Get a value from $_COOKIE. + * + * @param cookie_items|null $default + * + * @return cookie_items|null + */ + public function cookie(string $key, mixed $default = null): array|string|null + { + return $this->cookie[$key] ?? $default; + } + + /** + * Set a value in $_COOKIE. + * + * @param cookie_items $value + */ + public function setCookie(string $key, array|string $value): self + { + $this->cookie[$key] = $value; + $_COOKIE[$key] = $value; + + return $this; + } + + /** + * Remove a key from $_COOKIE. + */ + public function unsetCookie(string $key): self + { + unset($this->cookie[$key], $_COOKIE[$key]); + + return $this; + } + + /** + * Get all $_COOKIE values. + * + * @return array + */ + public function getCookieArray(): array + { + return $this->cookie; + } + + /** + * Set the entire $_COOKIE array. + * + * @param array $array + */ + public function setCookieArray(array $array): self + { + $this->cookie = $array; + $_COOKIE = $array; + + return $this; + } + + /** + * Get a value from $_REQUEST. + * + * @param request_items|null $default + * + * @return request_items|null + */ + public function request(string $key, mixed $default = null): array|string|null + { + return $this->request[$key] ?? $default; + } + + /** + * Set a value in $_REQUEST. + * + * @param request_items $value + */ + public function setRequest(string $key, array|string $value): self + { + $this->request[$key] = $value; + $_REQUEST[$key] = $value; + + return $this; + } + + /** + * Remove a key from $_REQUEST. + */ + public function unsetRequest(string $key): self + { + unset($this->request[$key], $_REQUEST[$key]); + + return $this; + } + + /** + * Get all $_REQUEST values. + * + * @return array + */ + public function getRequestArray(): array + { + return $this->request; + } + + /** + * Set the entire $_REQUEST array. + * + * @param array $array + */ + public function setRequestArray(array $array): self + { + $this->request = $array; + $_REQUEST = $array; + + return $this; + } + + /** + * Get all $_FILES values. + * + * @return files_items + */ + public function getFilesArray(): array + { + return $this->files; + } + + /** + * Set the entire $_FILES array. + * + * @param files_items $array + */ + public function setFilesArray(array $array): self + { + $this->files = $array; + $_FILES = $array; + + return $this; + } + + /** + * Get a superglobal array by name. + * + * @param string $name The superglobal name (server, get, post, cookie, files, request) + * + * @return array + * + * @throws InvalidArgumentException If the superglobal name is invalid + */ + public function getGlobalArray(string $name): array + { + return match ($name) { + 'server' => $this->server, + 'get' => $this->get, + 'post' => $this->post, + 'cookie' => $this->cookie, + 'files' => $this->files, + 'request' => $this->request, + default => throw new InvalidArgumentException( + "Invalid superglobal name '{$name}'. Must be one of: server, get, post, cookie, files, request.", + ), + }; + } + + /** + * Set a superglobal array by name. + * + * @param string $name The superglobal name (server, get, post, cookie, files, request) + * @param array $array The array to set + * + * @throws InvalidArgumentException If the superglobal name is invalid + */ + public function setGlobalArray(string $name, array $array): void + { + match ($name) { + 'server' => $this->setServerArray($array), + 'get' => $this->setGetArray($array), + 'post' => $this->setPostArray($array), + 'cookie' => $this->setCookieArray($array), + 'files' => $this->setFilesArray($array), + 'request' => $this->setRequestArray($array), + default => throw new InvalidArgumentException( + "Invalid superglobal name '{$name}'. Must be one of: server, get, post, cookie, files, request.", + ), + }; } } diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index c3b974448e43..a19e396e2392 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -397,7 +397,7 @@ protected function populateGlobals(string $name, Request $request, ?array $param } if ($name === 'post') { - $request->setGlobal($name, $params); + $request->setGlobal($name, $params ?? []); $request->setGlobal( 'request', (array) $request->fetchGlobal('post') + (array) $request->fetchGlobal('get'), diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php index 30e4b149de99..9d780d7b437f 100644 --- a/tests/system/API/TransformerTest.php +++ b/tests/system/API/TransformerTest.php @@ -44,6 +44,8 @@ private function createMockRequest(string $query = ''): IncomingRequest if ($query !== '') { parse_str($query, $get); $request->setGlobal('get', $get); + } else { + $request->setGlobal('get', []); } return $request; diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index b29ef7bfc12f..146d1066766e 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -13,7 +13,9 @@ namespace CodeIgniter\CLI; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\PhpStreamWrapper; use CodeIgniter\Test\StreamFilterTrait; @@ -29,6 +31,13 @@ final class CLITest extends CIUnitTestCase { use StreamFilterTrait; + protected function setUp(): void + { + parent::setUp(); + + Services::injectMock('superglobals', new Superglobals()); + } + public function testNew(): void { $actual = new CLI(); @@ -454,12 +463,12 @@ public function testWrap(): void public function testParseCommand(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'ignored', 'b', 'c', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); CLI::init(); $this->assertNull(CLI::getSegment(3)); @@ -473,7 +482,7 @@ public function testParseCommand(): void public function testParseCommandMixed(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'ignored', 'b', 'c', @@ -485,7 +494,7 @@ public function testParseCommandMixed(): void '--fix', '--opt-in', 'sure', - ]; + ]); CLI::init(); $this->assertNull(CLI::getSegment(7)); @@ -502,14 +511,14 @@ public function testParseCommandMixed(): void public function testParseCommandOption(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'ignored', 'b', 'c', '--parm', 'pvalue', 'd', - ]; + ]); CLI::init(); $this->assertSame(['parm' => 'pvalue'], CLI::getOptions()); @@ -524,7 +533,7 @@ public function testParseCommandOption(): void public function testParseCommandMultipleOptions(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'ignored', 'b', 'c', @@ -534,7 +543,7 @@ public function testParseCommandMultipleOptions(): void '--p2', '--p3', 'value 3', - ]; + ]); CLI::init(); $this->assertSame(['parm' => 'pvalue', 'p2' => null, 'p3' => 'value 3'], CLI::getOptions()); diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index 4d66a48447b4..8110698c4212 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -15,7 +15,9 @@ use CodeIgniter\CodeIgniter; use CodeIgniter\Config\DotEnv; +use CodeIgniter\Config\Services; use CodeIgniter\Events\Events; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCLIConfig; use CodeIgniter\Test\Mock\MockCodeIgniter; @@ -34,12 +36,14 @@ protected function setUp(): void { parent::setUp(); + Services::injectMock('superglobals', new Superglobals()); + $env = new DotEnv(ROOTPATH); $env->load(); // Set environment values that would otherwise stop the framework from functioning during tests. - if (! isset($_SERVER['app.baseURL'])) { - $_SERVER['app.baseURL'] = 'http://example.com/'; + if (service('superglobals')->server('app.baseURL') === null) { + service('superglobals')->setServer('app.baseURL', 'http://example.com/'); } $this->app = new MockCodeIgniter(new MockCLIConfig()); @@ -173,8 +177,8 @@ public function testHelpArgumentAndHelpOptionCombined(): void */ protected function initCLI(...$command): void { - $_SERVER['argv'] = ['spark', ...$command]; - $_SERVER['argc'] = count($_SERVER['argv']); + service('superglobals')->setServer('argv', ['spark', ...$command]); + service('superglobals')->setServer('argc', count(service('superglobals')->server('argv'))); CLI::init(); } diff --git a/tests/system/Cache/ResponseCacheTest.php b/tests/system/Cache/ResponseCacheTest.php index 198caefbc015..a05d37d96f54 100644 --- a/tests/system/Cache/ResponseCacheTest.php +++ b/tests/system/Cache/ResponseCacheTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Cache; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; @@ -20,6 +21,7 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCache; use Config\App; @@ -35,6 +37,13 @@ #[Group('Others')] final class ResponseCacheTest extends CIUnitTestCase { + protected function setUp(): void + { + parent::setUp(); + + Services::injectMock('superglobals', new Superglobals()); + } + /** * @param array $query */ @@ -58,7 +67,7 @@ private function createIncomingRequest(string $uri = '', array $query = [], App */ private function createCLIRequest(array $params = [], App $app = new App()): CLIRequest { - $_SERVER['argv'] = ['public/index.php', ...$params]; + service('superglobals')->setServer('argv', ['public/index.php', ...$params]); $superglobals = service('superglobals'); $superglobals->setServer('SCRIPT_NAME', 'public/index.php'); diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index 05e20cd7b361..bab5830e6685 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -55,7 +55,9 @@ protected function setUp(): void parent::setUp(); $this->resetServices(); - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; + Services::injectMock('superglobals', new Superglobals()); + + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); $this->codeigniter = new MockCodeIgniter(new App()); @@ -72,8 +74,9 @@ protected function tearDown(): void public function testRunEmptyDefaultRoute(): void { - $_SERVER['argv'] = ['index.php']; - $_SERVER['argc'] = 1; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php']); + $superglobals->setServer('argc', 1); ob_start(); $this->codeigniter->run(); @@ -94,8 +97,9 @@ public function testOutputBufferingControl(): void public function testRunEmptyDefaultRouteReturnResponse(): void { - $_SERVER['argv'] = ['index.php']; - $_SERVER['argc'] = 1; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php']); + $superglobals->setServer('argc', 1); $response = $this->codeigniter->run(null, true); $this->assertInstanceOf(ResponseInterface::class, $response); @@ -105,11 +109,11 @@ public function testRunEmptyDefaultRouteReturnResponse(): void public function testRunClosureRoute(): void { - $_SERVER['argv'] = ['index.php', 'pages/about']; - $_SERVER['argc'] = 2; - - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php', 'pages/about']); + $superglobals->setServer('argc', 2); + $superglobals->setServer('REQUEST_URI', '/pages/about'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -131,9 +135,10 @@ public function testRunClosureRoute(): void */ public function testRun404Override(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'GET'); + $superglobals->setServer('REQUEST_URI', '/pages/about'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -152,8 +157,9 @@ public function testRun404Override(): void public function testRun404OverrideControllerReturnsResponse(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php', '/']); + $superglobals->setServer('argc', 2); // Inject mock router. $routes = service('routes'); @@ -171,8 +177,9 @@ public function testRun404OverrideControllerReturnsResponse(): void public function testRun404OverrideReturnResponse(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php', '/']); + $superglobals->setServer('argc', 2); // Inject mock router. $routes = service('routes'); @@ -189,8 +196,9 @@ public function testRun404OverrideReturnResponse(): void public function testRun404OverrideByClosure(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php', '/']); + $superglobals->setServer('argc', 2); // Inject mock router. $routes = new RouteCollection(service('locator'), new Modules(), new Routing()); @@ -211,11 +219,11 @@ public function testRun404OverrideByClosure(): void public function testControllersCanReturnString(): void { - $_SERVER['argv'] = ['index.php', 'pages/about']; - $_SERVER['argc'] = 2; - - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php', 'pages/about']); + $superglobals->setServer('argc', 2); + $superglobals->setServer('REQUEST_URI', '/pages/about'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -235,11 +243,11 @@ public function testControllersCanReturnString(): void public function testControllersCanReturnResponseObject(): void { - $_SERVER['argv'] = ['index.php', 'pages/about']; - $_SERVER['argc'] = 2; - - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php', 'pages/about']); + $superglobals->setServer('argc', 2); + $superglobals->setServer('REQUEST_URI', '/pages/about'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -264,11 +272,11 @@ public function testControllersCanReturnResponseObject(): void */ public function testControllersCanReturnDownloadResponseObject(): void { - $_SERVER['argv'] = ['index.php', 'pages/about']; - $_SERVER['argc'] = 2; - - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('argv', ['index.php', 'pages/about']) + ->setServer('argc', 2) + ->setServer('REQUEST_URI', '/pages/about') + ->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -289,11 +297,11 @@ public function testControllersCanReturnDownloadResponseObject(): void public function testRunExecuteFilterByClassName(): void { - $_SERVER['argv'] = ['index.php', 'pages/about']; - $_SERVER['argc'] = 2; - - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('argv', ['index.php', 'pages/about']) + ->setServer('argc', 2) + ->setServer('REQUEST_URI', '/pages/about') + ->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -317,11 +325,11 @@ public function testRunExecuteFilterByClassName(): void public function testRegisterSameFilterTwiceWithDifferentArgument(): void { - $_SERVER['argv'] = ['index.php', 'pages/about']; - $_SERVER['argc'] = 2; - - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('argv', ['index.php', 'pages/about']) + ->setServer('argc', 2) + ->setServer('REQUEST_URI', '/pages/about') + ->setServer('SCRIPT_NAME', '/index.php'); $routes = service('routes'); $routes->add( @@ -355,11 +363,11 @@ public function testRegisterSameFilterTwiceWithDifferentArgument(): void public function testDisableControllerFilters(): void { - $_SERVER['argv'] = ['index.php', 'pages/about']; - $_SERVER['argc'] = 2; - - $_SERVER['REQUEST_URI'] = '/pages/about'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('argv', ['index.php', 'pages/about']) + ->setServer('argc', 2) + ->setServer('REQUEST_URI', '/pages/about') + ->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -383,8 +391,9 @@ public function testDisableControllerFilters(): void public function testResponseConfigEmpty(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + service('superglobals') + ->setServer('argv', ['index.php', '/']) + ->setServer('argc', 2); $response = service('response', null, false); @@ -393,8 +402,9 @@ public function testResponseConfigEmpty(): void public function testRoutesIsEmpty(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + service('superglobals') + ->setServer('argv', ['index.php', '/']) + ->setServer('argc', 2); // Inject mock router. $router = service('router', null, service('incomingrequest'), false); @@ -409,10 +419,10 @@ public function testRoutesIsEmpty(): void public function testTransfersCorrectHTTPVersion(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; - - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/2.0'; + service('superglobals') + ->setServer('argv', ['index.php', '/']) + ->setServer('argc', 2) + ->setServer('SERVER_PROTOCOL', 'HTTP/2.0'); ob_start(); $this->codeigniter->run(); @@ -425,10 +435,10 @@ public function testTransfersCorrectHTTPVersion(): void public function testSupportsHttp3(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; - - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/3.0'; + service('superglobals') + ->setServer('argv', ['index.php', '/']) + ->setServer('argc', 2) + ->setServer('SERVER_PROTOCOL', 'HTTP/3.0'); ob_start(); $this->codeigniter->run(); @@ -441,8 +451,9 @@ public function testSupportsHttp3(): void public function testIgnoringErrorSuppressedByAt(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + service('superglobals') + ->setServer('argv', ['index.php', '/']) + ->setServer('argc', 2); ob_start(); @unlink('inexistent-file'); @@ -454,8 +465,9 @@ public function testIgnoringErrorSuppressedByAt(): void public function testRunForceSecure(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + service('superglobals') + ->setServer('argv', ['index.php', '/']) + ->setServer('argc', 2); $filterConfig = config(FiltersConfig::class); $filterConfig->required['before'][] = 'forcehttps'; @@ -480,11 +492,11 @@ public function testRunForceSecure(): void public function testRunRedirectionWithNamed(): void { - $_SERVER['argv'] = ['index.php', 'example']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'example']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/example'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals')->setServer('REQUEST_URI', '/example'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -504,11 +516,11 @@ public function testRunRedirectionWithNamed(): void public function testRunRedirectionWithURI(): void { - $_SERVER['argv'] = ['index.php', 'example']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'example']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/example'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals')->setServer('REQUEST_URI', '/example'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -531,13 +543,13 @@ public function testRunRedirectionWithURI(): void */ public function testRunRedirectionWithGET(): void { - $_SERVER['argv'] = ['index.php', 'example']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'example']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/example'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/example'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); // Inject mock router. $routes = service('routes'); @@ -558,13 +570,13 @@ public function testRunRedirectionWithGET(): void public function testRunRedirectionWithGETAndHTTPCode301(): void { - $_SERVER['argv'] = ['index.php', 'example']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'example']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/example'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/example'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); // Inject mock router. $routes = service('routes'); @@ -583,13 +595,13 @@ public function testRunRedirectionWithGETAndHTTPCode301(): void public function testRunRedirectionWithPOSTAndHTTPCode301(): void { - $_SERVER['argv'] = ['index.php', 'example']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'example']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/example'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_URI', '/example'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); // Inject mock router. $routes = service('routes'); @@ -608,8 +620,8 @@ public function testRunRedirectionWithPOSTAndHTTPCode301(): void public function testStoresPreviousURL(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', '/']); + service('superglobals')->setServer('argc', 2); // Inject mock router. $router = service('router', null, service('incomingrequest'), false); @@ -625,13 +637,13 @@ public function testStoresPreviousURL(): void public function testNotStoresPreviousURL(): void { - $_SERVER['argv'] = ['index.php', 'example']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'example']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/example'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/example'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); // Inject mock router. $routes = service('routes'); @@ -649,11 +661,11 @@ public function testNotStoresPreviousURL(): void public function testNotStoresPreviousURLByCheckingContentType(): void { - $_SERVER['argv'] = ['index.php', 'image']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'image']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/image'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals')->setServer('REQUEST_URI', '/image'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -679,8 +691,8 @@ public function testNotStoresPreviousURLByCheckingContentType(): void */ public function testRunDefaultRoute(): void { - $_SERVER['argv'] = ['index.php', '/']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', '/']); + service('superglobals')->setServer('argc', 2); ob_start(); $this->codeigniter->run(); @@ -691,13 +703,13 @@ public function testRunDefaultRoute(): void public function testRunCLIRoute(): void { - $_SERVER['argv'] = ['index.php', 'cli']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'cli']); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/cli'; - $_SERVER['SCRIPT_NAME'] = 'public/index.php'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'CLI'; + service('superglobals')->setServer('REQUEST_URI', '/cli'); + service('superglobals')->setServer('SCRIPT_NAME', 'public/index.php'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('REQUEST_METHOD', 'CLI'); $routes = service('routes'); $routes->cli('cli', '\Tests\Support\Controllers\Popcorn::index'); @@ -711,15 +723,15 @@ public function testRunCLIRoute(): void public function testSpoofRequestMethodCanUsePUT(): void { - $_SERVER['argv'] = ['index.php']; - $_SERVER['argc'] = 1; + service('superglobals')->setServer('argv', ['index.php']); + service('superglobals')->setServer('argc', 1); - $_SERVER['REQUEST_URI'] = '/'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_URI', '/'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); - $_POST['_method'] = Method::PUT; + service('superglobals')->setPost('_method', Method::PUT); $routes = service('routes'); $routes->setDefaultNamespace('App\Controllers'); @@ -736,15 +748,15 @@ public function testSpoofRequestMethodCanUsePUT(): void public function testSpoofRequestMethodCannotUseGET(): void { - $_SERVER['argv'] = ['index.php']; - $_SERVER['argc'] = 1; + service('superglobals')->setServer('argv', ['index.php']); + service('superglobals')->setServer('argc', 1); - $_SERVER['REQUEST_URI'] = '/'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_URI', '/'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); - $_POST['_method'] = 'GET'; + service('superglobals')->setPost('_method', 'GET'); $routes = service('routes'); $routes->setDefaultNamespace('App\Controllers'); @@ -772,8 +784,8 @@ public function testPageCacheSendSecureHeaders(): void // Clear Page cache command('cache:clear'); - $_SERVER['REQUEST_URI'] = '/test'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals')->setServer('REQUEST_URI', '/test'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); $routes = service('routes'); $routes->add('test', static function () { @@ -850,9 +862,9 @@ public function testPageCacheWithCacheQueryString( // Generate request to each URL from the testing array foreach ($testingUrls as $testingUrl) { $this->resetServices(); - $_SERVER['REQUEST_URI'] = '/' . $testingUrl; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $this->codeigniter = new MockCodeIgniter(new App()); + service('superglobals')->setServer('REQUEST_URI', '/' . $testingUrl); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + $this->codeigniter = new MockCodeIgniter(new App()); $routes = service('routes', true); $routePath = explode('?', $testingUrl)[0]; @@ -931,11 +943,11 @@ public static function providePageCacheWithCacheQueryString(): iterable */ public function testRunControllerNotFoundBeforeFilter(): void { - $_SERVER['argv'] = ['index.php']; - $_SERVER['argc'] = 1; + service('superglobals')->setServer('argv', ['index.php']); + service('superglobals')->setServer('argc', 1); - $_SERVER['REQUEST_URI'] = '/cannotFound'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals')->setServer('REQUEST_URI', '/cannotFound'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router. $routes = service('routes'); @@ -976,12 +988,12 @@ public function testStartControllerPermitsInvoke(): void public function testRouteAttributeCacheIntegration(): void { - $_SERVER['argv'] = ['index.php', 'attribute/cached']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/cached']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/cached'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); - Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/cached'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); // Clear cache before test cache()->clean(); @@ -1008,11 +1020,11 @@ public function testRouteAttributeCacheIntegration(): void // Second request - should return cached version with same timestamp $this->resetServices(); - $_SERVER['argv'] = ['index.php', 'attribute/cached']; - $_SERVER['argc'] = 2; - Services::superglobals()->setServer('REQUEST_URI', '/attribute/cached'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); - Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + service('superglobals')->setServer('argv', ['index.php', 'attribute/cached']); + service('superglobals')->setServer('argc', 2); + service('superglobals')->setServer('REQUEST_URI', '/attribute/cached'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->codeigniter = new MockCodeIgniter(new App()); $routes = service('routes'); @@ -1036,11 +1048,11 @@ public function testRouteAttributeCacheIntegration(): void public function testRouteAttributeFilterIntegration(): void { - $_SERVER['argv'] = ['index.php', 'attribute/filtered']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/filtered']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/filtered'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/filtered'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Register the test filter $filterConfig = config('Filters'); @@ -1064,11 +1076,11 @@ public function testRouteAttributeFilterIntegration(): void public function testRouteAttributeFilterWithParamsIntegration(): void { - $_SERVER['argv'] = ['index.php', 'attribute/filteredWithParams']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/filteredWithParams']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/filteredWithParams'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/filteredWithParams'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Register the test filter $filterConfig = config('Filters'); @@ -1092,11 +1104,11 @@ public function testRouteAttributeFilterWithParamsIntegration(): void public function testRouteAttributeRestrictIntegration(): void { - $_SERVER['argv'] = ['index.php', 'attribute/restricted']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/restricted']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/restricted'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/restricted'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router $routes = service('routes'); @@ -1114,11 +1126,11 @@ public function testRouteAttributeRestrictIntegration(): void public function testRouteAttributeRestrictThrowsException(): void { - $_SERVER['argv'] = ['index.php', 'attribute/restricted']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/restricted']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/shouldBeRestricted'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/shouldBeRestricted'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router $routes = service('routes'); @@ -1135,11 +1147,11 @@ public function testRouteAttributeRestrictThrowsException(): void public function testRouteAttributeMultipleAttributesIntegration(): void { - $_SERVER['argv'] = ['index.php', 'attribute/multiple']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/multiple']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/multiple'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/multiple'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Register the test filter $filterConfig = config('Filters'); @@ -1163,11 +1175,11 @@ public function testRouteAttributeMultipleAttributesIntegration(): void public function testRouteAttributeNoAttributesIntegration(): void { - $_SERVER['argv'] = ['index.php', 'attribute/none']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/none']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/none'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/none'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); // Inject mock router $routes = service('routes'); @@ -1185,12 +1197,12 @@ public function testRouteAttributeNoAttributesIntegration(): void public function testRouteAttributeCustomCacheKeyIntegration(): void { - $_SERVER['argv'] = ['index.php', 'attribute/customkey']; - $_SERVER['argc'] = 2; + service('superglobals')->setServer('argv', ['index.php', 'attribute/customkey']); + service('superglobals')->setServer('argc', 2); - Services::superglobals()->setServer('REQUEST_URI', '/attribute/customkey'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); - Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/customkey'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); // Clear cache before test cache()->clean(); @@ -1219,9 +1231,9 @@ public function testRouteAttributeCustomCacheKeyIntegration(): void public function testRouteAttributesDisabledInConfig(): void { - Services::superglobals()->setServer('REQUEST_URI', '/attribute/filtered'); - Services::superglobals()->setServer('SCRIPT_NAME', '/index.php'); - Services::superglobals()->setServer('REQUEST_METHOD', 'GET'); + service('superglobals')->setServer('REQUEST_URI', '/attribute/filtered'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); // Disable route attributes in config BEFORE creating CodeIgniter instance $routing = config('routing'); diff --git a/tests/system/Commands/EnvironmentCommandTest.php b/tests/system/Commands/EnvironmentCommandTest.php index b62ade32c827..597805ee8a38 100644 --- a/tests/system/Commands/EnvironmentCommandTest.php +++ b/tests/system/Commands/EnvironmentCommandTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Commands; +use CodeIgniter\Config\Services; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; @@ -35,6 +37,8 @@ protected function setUp(): void if (is_file($this->envPath)) { rename($this->envPath, $this->backupEnvPath); } + + Services::injectMock('superglobals', new Superglobals()); } protected function tearDown(): void @@ -49,7 +53,8 @@ protected function tearDown(): void rename($this->backupEnvPath, $this->envPath); } - $_SERVER['CI_ENVIRONMENT'] = $_ENV['CI_ENVIRONMENT'] = ENVIRONMENT; + service('superglobals')->setServer('CI_ENVIRONMENT', ENVIRONMENT); + $_ENV['CI_ENVIRONMENT'] = ENVIRONMENT; } public function testUsingCommandWithNoArgumentsGivesCurrentEnvironment(): void @@ -90,7 +95,7 @@ public function testDefaultShippedEnvIsMissing(): void public function testSettingNewEnvIsSuccess(): void { // default env file has `production` env in it - $_SERVER['CI_ENVIRONMENT'] = 'production'; + service('superglobals')->setServer('CI_ENVIRONMENT', 'production'); command('env development'); $this->assertStringContainsString('Environment is successfully changed to', $this->getStreamFilterBuffer()); diff --git a/tests/system/Commands/GenerateKeyTest.php b/tests/system/Commands/GenerateKeyTest.php index 6dd7a7330c36..6ed0d6c94b93 100644 --- a/tests/system/Commands/GenerateKeyTest.php +++ b/tests/system/Commands/GenerateKeyTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Commands; +use CodeIgniter\Config\Services; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Filters\CITestStreamFilter; use CodeIgniter\Test\StreamFilterTrait; @@ -37,6 +39,8 @@ protected function setUp(): void { parent::setUp(); + Services::injectMock('superglobals', new Superglobals()); + $this->envPath = ROOTPATH . '.env'; $this->backupEnvPath = ROOTPATH . '.env.backup'; @@ -71,7 +75,8 @@ protected function getBuffer(): string protected function resetEnvironment(): void { putenv('encryption.key'); - unset($_ENV['encryption.key'], $_SERVER['encryption.key']); + unset($_ENV['encryption.key']); + service('superglobals')->unsetServer('encryption.key'); } public function testGenerateKeyShowsEncodedKey(): void diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index c058a270504e..462208100675 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Config\BaseService; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services as CodeIgniterServices; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\Exceptions\RedirectException; @@ -67,6 +68,8 @@ protected function setUp(): void $this->resetServices(); parent::setUp(); + + CodeIgniterServices::injectMock('superglobals', new Superglobals()); } public function testStringifyAttributes(): void @@ -103,7 +106,7 @@ public function testEnvReturnsDefault(): void public function testEnvGetsFromSERVER(): void { - $_SERVER['foo'] = 'bar'; + service('superglobals')->setServer('foo', 'bar'); $this->assertSame('bar', env('foo', 'baz')); } @@ -135,7 +138,7 @@ private function createRouteCollection(): RouteCollection public function testRedirectReturnsRedirectResponse(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $response = $this->createMock(Response::class); Services::injectMock('response', $response); @@ -425,7 +428,7 @@ public function testOldInput(): void { $this->injectSessionMock(); // setup from RedirectResponseTest... - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->config = new App(); $this->config->baseURL = 'http://example.com/'; @@ -437,12 +440,13 @@ public function testOldInput(): void Services::injectMock('request', $this->request); // setup & ask for a redirect... - $_SESSION = []; - $_GET = ['foo' => 'bar']; - $_POST = [ + $_SESSION = []; + $superglobals = service('superglobals'); + $superglobals->setGetArray(['foo' => 'bar']); + $superglobals->setPostArray([ 'bar' => 'baz', 'zibble' => 'fritz', - ]; + ]); $response = new RedirectResponse(new App()); $response->withInput(); @@ -459,7 +463,7 @@ public function testOldInputSerializeData(): void { $this->injectSessionMock(); // setup from RedirectResponseTest... - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->config = new App(); $this->config->baseURL = 'http://example.com/'; @@ -471,11 +475,12 @@ public function testOldInputSerializeData(): void Services::injectMock('request', $this->request); // setup & ask for a redirect... - $_SESSION = []; - $_GET = []; - $_POST = [ + $_SESSION = []; + $superglobals = service('superglobals'); + $superglobals->setGetArray([]); + $superglobals->setPostArray([ 'zibble' => serialize('fritz'), - ]; + ]); $response = new RedirectResponse(new App()); $response->withInput(); @@ -494,7 +499,7 @@ public function testOldInputArray(): void { $this->injectSessionMock(); // setup from RedirectResponseTest... - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->config = new App(); $this->config->baseURL = 'http://example.com/'; @@ -512,9 +517,10 @@ public function testOldInputArray(): void ]; // setup & ask for a redirect... - $_SESSION = []; - $_GET = []; - $_POST = ['location' => $locations]; + $_SESSION = []; + $superglobals = service('superglobals'); + $superglobals->setGetArray([]); + $superglobals->setPostArray(['location' => $locations]); $response = new RedirectResponse(new App()); $response->withInput(); diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index 9158db34140f..5cc3d1682617 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; use Encryption; @@ -56,6 +57,8 @@ protected function setUp(): void } BaseConfig::reset(); + + Services::injectMock('superglobals', new Superglobals()); } protected function tearDown(): void @@ -198,7 +201,8 @@ public function testSetsDefaultValues(): void public function testSetsDefaultValuesEncryptionUsingHex2Bin(): void { putenv('encryption.key'); - unset($_ENV['encryption.key'], $_SERVER['encryption.key']); // @phpstan-ignore codeigniter.superglobalAccess + unset($_ENV['encryption.key']); + service('superglobals')->unsetServer('encryption.key'); $dotenv = new DotEnv($this->fixturesFolder, 'encryption.env'); $dotenv->load(); @@ -214,7 +218,8 @@ public function testSetsDefaultValuesEncryptionUsingHex2Bin(): void public function testSetDefaultValuesEncryptionUsingBase64(): void { putenv('encryption.key'); - unset($_ENV['encryption.key'], $_SERVER['encryption.key']); // @phpstan-ignore codeigniter.superglobalAccess + unset($_ENV['encryption.key']); + service('superglobals')->unsetServer('encryption.key'); $dotenv = new DotEnv($this->fixturesFolder, 'base64encryption.env'); $dotenv->load(); diff --git a/tests/system/Config/DotEnvTest.php b/tests/system/Config/DotEnvTest.php index 4507c8a3e8bf..505f4bedcbcb 100644 --- a/tests/system/Config/DotEnvTest.php +++ b/tests/system/Config/DotEnvTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Config; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; @@ -46,6 +47,8 @@ protected function setUp(): void $file = 'unreadable.env'; $path = rtrim($this->fixturesFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $file; chmod($path, 0644); + + Services::injectMock('superglobals', new Superglobals()); } protected function tearDown(): void @@ -180,8 +183,8 @@ public function testNamespacedVariables(): void public function testLoadsGetServerVar(): void { - $_SERVER['SER_VAR'] = 'TT'; - $dotenv = new DotEnv($this->fixturesFolder, 'nested.env'); + service('superglobals')->setServer('SER_VAR', 'TT'); + $dotenv = new DotEnv($this->fixturesFolder, 'nested.env'); $dotenv->load(); $this->assertSame('TT', $_ENV['NVAR7']); diff --git a/tests/system/Encryption/EncryptionTest.php b/tests/system/Encryption/EncryptionTest.php index de372e232411..48fd6dc78e73 100644 --- a/tests/system/Encryption/EncryptionTest.php +++ b/tests/system/Encryption/EncryptionTest.php @@ -13,7 +13,9 @@ namespace CodeIgniter\Encryption; +use CodeIgniter\Config\Services as CodeIgniterServices; use CodeIgniter\Encryption\Exceptions\EncryptionException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\Encryption as EncryptionConfig; use Config\Services; @@ -31,11 +33,14 @@ public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); + CodeIgniterServices::injectMock('superglobals', new Superglobals()); + if (is_file(ROOTPATH . '.env')) { rename(ROOTPATH . '.env', ROOTPATH . '.env.bak'); putenv('encryption.key'); - unset($_ENV['encryption.key'], $_SERVER['encryption.key']); + unset($_ENV['encryption.key']); + service('superglobals')->unsetServer('encryption.key'); } } diff --git a/tests/system/Filters/DebugToolbarTest.php b/tests/system/Filters/DebugToolbarTest.php index 4968cede04f0..905dd46cf15a 100644 --- a/tests/system/Filters/DebugToolbarTest.php +++ b/tests/system/Filters/DebugToolbarTest.php @@ -13,11 +13,13 @@ namespace CodeIgniter\Filters; +use CodeIgniter\Config\Services; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\Filters as FilterConfig; use PHPUnit\Framework\Attributes\BackupGlobals; @@ -41,13 +43,15 @@ protected function setUp(): void { parent::setUp(); + Services::injectMock('superglobals', new Superglobals()); + $this->request = service('request'); $this->response = service('response'); } public function testDebugToolbarFilter(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = new FilterConfig(); $config->globals = [ diff --git a/tests/system/Filters/FiltersTest.php b/tests/system/Filters/FiltersTest.php index b2daf0ec2b07..bf65d43dd0de 100644 --- a/tests/system/Filters/FiltersTest.php +++ b/tests/system/Filters/FiltersTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Filters; +use CodeIgniter\Config\Services; use CodeIgniter\Filters\Exceptions\FilterException; use CodeIgniter\Filters\fixtures\GoogleCurious; use CodeIgniter\Filters\fixtures\GoogleEmpty; @@ -25,6 +26,7 @@ use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ConfigFromArrayTrait; use CodeIgniter\Test\Mock\MockAppConfig; @@ -67,6 +69,7 @@ protected function setUp(): void service('autoloader')->addNamespace($defaults); $_SERVER = []; + Services::injectMock('superglobals', new Superglobals()); $this->response = service('response'); } @@ -80,11 +83,11 @@ private function createFilters(FiltersConfig $config, $request = null): Filters public function testProcessMethodDetectsCLI(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'spark', 'list', - ]; - $_SERVER['argc'] = 2; + ]); + service('superglobals')->setServer('argc', 2); $config = [ 'aliases' => ['foo' => ''], @@ -108,7 +111,7 @@ public function testProcessMethodDetectsCLI(): void public function testProcessMethodDetectsGetRequests(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['foo' => ''], @@ -129,7 +132,7 @@ public function testProcessMethodDetectsGetRequests(): void public function testProcessMethodRespectsMethod(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -154,7 +157,7 @@ public function testProcessMethodRespectsMethod(): void public function testProcessMethodIgnoresMethod(): void { - $_SERVER['REQUEST_METHOD'] = 'DELETE'; + service('superglobals')->setServer('REQUEST_METHOD', 'DELETE'); $config = [ 'aliases' => [ @@ -179,7 +182,7 @@ public function testProcessMethodIgnoresMethod(): void public function testProcessMethodProcessGlobals(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -216,7 +219,7 @@ public function testProcessMethodProcessGlobals(): void #[DataProvider('provideProcessMethodProcessGlobalsWithExcept')] public function testProcessMethodProcessGlobalsWithExcept($except): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -267,7 +270,7 @@ public static function provideProcessMethodProcessGlobalsWithExcept(): iterable public function testProcessMethodProcessesFiltersBefore(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -300,7 +303,7 @@ public function testProcessMethodProcessesFiltersBefore(): void public function testProcessMethodProcessesFiltersAfter(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -333,7 +336,7 @@ public function testProcessMethodProcessesFiltersAfter(): void public function testProcessMethodProcessesCombined(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -381,7 +384,7 @@ public function testProcessMethodProcessesCombined(): void public function testProcessMethodProcessesCombinedAfterForToolbar(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -422,7 +425,7 @@ public function testProcessMethodProcessesCombinedAfterForToolbar(): void public function testRunThrowsWithInvalidAlias(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [], @@ -442,7 +445,7 @@ public function testRunThrowsWithInvalidAlias(): void public function testCustomFiltersLoad(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [], @@ -467,7 +470,7 @@ public function testCustomFiltersLoad(): void */ public function testAllCustomFiltersAreDiscoveredInConstructor(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [], @@ -482,7 +485,7 @@ public function testAllCustomFiltersAreDiscoveredInConstructor(): void public function testRunThrowsWithInvalidClassType(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['invalid' => InvalidClass::class], @@ -502,7 +505,7 @@ public function testRunThrowsWithInvalidClassType(): void public function testRunDoesBefore(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['google' => GoogleMe::class], @@ -522,7 +525,7 @@ public function testRunDoesBefore(): void public function testRunDoesAfter(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['google' => GoogleMe::class], @@ -542,7 +545,7 @@ public function testRunDoesAfter(): void public function testShortCircuit(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['banana' => GoogleYou::class], @@ -563,7 +566,7 @@ public function testShortCircuit(): void public function testOtherResult(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -593,7 +596,7 @@ public function testOtherResult(): void #[DataProvider('provideBeforeExcept')] public function testBeforeExcept(string $uri, $except, array $expected): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -703,7 +706,7 @@ public static function provideBeforeExcept(): iterable public function testAfterExceptString(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -736,7 +739,7 @@ public function testAfterExceptString(): void public function testAfterExceptInapplicable(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -772,7 +775,7 @@ public function testAfterExceptInapplicable(): void public function testAddFilter(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['google' => GoogleMe::class], @@ -793,7 +796,7 @@ public function testAddFilter(): void public function testAddFilterSection(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = []; $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); @@ -809,7 +812,7 @@ public function testAddFilterSection(): void public function testInitializeTwice(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = []; $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); @@ -826,7 +829,7 @@ public function testInitializeTwice(): void public function testEnableFilter(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['google' => GoogleMe::class], @@ -847,7 +850,7 @@ public function testEnableFilter(): void public function testFiltersWithArguments(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['role' => Role::class], @@ -879,7 +882,7 @@ public function testFiltersWithArguments(): void public function testFilterWithDiffernetArguments(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['role' => Role::class], @@ -905,7 +908,7 @@ public function testFilterWithDiffernetArguments(): void public function testFilterWithoutArgumentsIsDefined(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['role' => Role::class], @@ -930,7 +933,7 @@ public function testFilterWithoutArgumentsIsDefined(): void public function testEnableFilterWithArguments(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['role' => Role::class], @@ -959,7 +962,7 @@ public function testEnableFilterWithArguments(): void public function testEnableFilterWithNoArguments(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['role' => Role::class], @@ -990,7 +993,7 @@ public function testEnableNonFilter(): void { $this->expectException(FilterException::class); - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['google' => GoogleMe::class], @@ -1011,7 +1014,7 @@ public function testEnableNonFilter(): void */ public function testMatchesURICaseInsensitively(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -1056,7 +1059,7 @@ public function testMatchesURICaseInsensitively(): void public function testMatchesURIWithUnicode(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -1105,7 +1108,7 @@ public function testMatchesURIWithUnicode(): void */ public function testFilterMatching(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -1141,7 +1144,7 @@ public function testFilterMatching(): void */ public function testGlobalFilterMatching(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -1183,7 +1186,7 @@ public function testGlobalFilterMatching(): void */ public function testCombinedFilterMatching(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -1231,7 +1234,7 @@ public function testCombinedFilterMatching(): void */ public function testSegmentedFilterMatching(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -1329,7 +1332,7 @@ public function testFilterClass(): void public function testReset(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => [ @@ -1352,7 +1355,7 @@ public function testReset(): void public function testRunRequiredDoesBefore(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['google' => GoogleMe::class], @@ -1371,7 +1374,7 @@ public function testRunRequiredDoesBefore(): void public function testRunRequiredDoesAfter(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $config = [ 'aliases' => ['google' => GoogleMe::class], diff --git a/tests/system/Filters/HoneypotTest.php b/tests/system/Filters/HoneypotTest.php index 7e29177f7d3f..630a882352ec 100644 --- a/tests/system/Filters/HoneypotTest.php +++ b/tests/system/Filters/HoneypotTest.php @@ -13,11 +13,13 @@ namespace CodeIgniter\Filters; +use CodeIgniter\Config\Services; use CodeIgniter\Honeypot\Exceptions\HoneypotException; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\Response; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\Honeypot; use PHPUnit\Framework\Attributes\BackupGlobals; @@ -50,9 +52,10 @@ protected function setUp(): void $this->config = new \Config\Filters(); $this->honey = new Honeypot(); - unset($_POST[$this->honey->name]); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST[$this->honey->name] = 'hey'; + Services::injectMock('superglobals', new Superglobals()); + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'POST'); + $superglobals->setPost($this->honey->name, 'hey'); } public function testBeforeTriggered(): void @@ -79,7 +82,7 @@ public function testBeforeClean(): void 'after' => [], ]; - unset($_POST[$this->honey->name]); + service('superglobals')->unsetPost($this->honey->name); $this->request = service('request', null, false); $this->response = service('response'); diff --git a/tests/system/Filters/InvalidCharsTest.php b/tests/system/Filters/InvalidCharsTest.php index a0546ef586bc..8aa935b14383 100644 --- a/tests/system/Filters/InvalidCharsTest.php +++ b/tests/system/Filters/InvalidCharsTest.php @@ -13,11 +13,13 @@ namespace CodeIgniter\Filters; +use CodeIgniter\Config\Services; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Security\Exceptions\SecurityException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockAppConfig; use PHPUnit\Framework\Attributes\DataProvider; @@ -37,9 +39,7 @@ protected function setUp(): void { parent::setUp(); - $_GET = []; - $_POST = []; - $_COOKIE = []; + Services::injectMock('superglobals', new Superglobals(null, [], [], [])); $this->request = $this->createRequest(); $this->invalidChars = new InvalidChars(); @@ -49,9 +49,10 @@ protected function tearDown(): void { parent::tearDown(); - $_GET = []; - $_POST = []; - $_COOKIE = []; + $superglobals = service('superglobals'); + $superglobals->setGetArray([]); + $superglobals->setPostArray([]); + $superglobals->setCookieArray([]); } private function createRequest(): IncomingRequest @@ -79,10 +80,11 @@ public function testBeforeDoNothingWhenCLIRequest(): void #[DoesNotPerformAssertions] public function testBeforeValidString(): void { - $_POST['val'] = [ + $superglobals = service('superglobals'); + $superglobals->setPost('val', [ 'valid string', - ]; - $_COOKIE['val'] = 'valid string'; + ]); + $superglobals->setCookie('val', 'valid string'); $this->invalidChars->before($this->request); } @@ -92,11 +94,11 @@ public function testBeforeInvalidUTF8StringCausesException(): void $this->expectException(SecurityException::class); $this->expectExceptionMessage('Invalid UTF-8 characters in post:'); - $sjisString = mb_convert_encoding('SJISの文字列です。', 'SJIS'); - $_POST['val'] = [ + $sjisString = mb_convert_encoding('SJISの文字列です。', 'SJIS'); + service('superglobals')->setPost('val', [ 'valid string', $sjisString, - ]; + ]); $this->invalidChars->before($this->request); } @@ -107,7 +109,7 @@ public function testBeforeInvalidControlCharCausesException(): void $this->expectExceptionMessage('Invalid Control characters in cookie:'); $stringWithNullChar = "String contains null char and line break.\0\n"; - $_COOKIE['val'] = $stringWithNullChar; + service('superglobals')->setCookie('val', $stringWithNullChar); $this->invalidChars->before($this->request); } @@ -116,7 +118,7 @@ public function testBeforeInvalidControlCharCausesException(): void #[DoesNotPerformAssertions] public function testCheckControlStringWithLineBreakAndTabReturnsTheString(string $input): void { - $_GET['val'] = $input; + service('superglobals')->setGet('val', $input); $this->invalidChars->before($this->request); } @@ -138,7 +140,7 @@ public function testCheckControlStringWithControlCharsCausesException(string $in $this->expectException(SecurityException::class); $this->expectExceptionMessage('Invalid Control characters in get:'); - $_GET['val'] = $input; + service('superglobals')->setGet('val', $input); $this->invalidChars->before($this->request); } diff --git a/tests/system/HTTP/CLIRequestTest.php b/tests/system/HTTP/CLIRequestTest.php index 854d8430528b..0bf3da0a6848 100644 --- a/tests/system/HTTP/CLIRequestTest.php +++ b/tests/system/HTTP/CLIRequestTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use PHPUnit\Framework\Attributes\BackupGlobals; @@ -34,22 +36,21 @@ protected function setUp(): void { parent::setUp(); - $this->request = new CLIRequest(new App()); + Services::injectMock('superglobals', new Superglobals(['argv' => []], [], [], [], [])); - $_POST = []; - $_GET = []; + $this->request = new CLIRequest(new App()); } public function testParsingSegments(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', 'profile', '-foo', 'bar', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -64,14 +65,14 @@ public function testParsingSegments(): void public function testParsingSegmentsWithHTMLMetaChars(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', 'abc < def', "McDonald's", 'aaa', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -88,7 +89,7 @@ public function testParsingSegmentsWithHTMLMetaChars(): void public function testParsingOptions(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', @@ -97,7 +98,7 @@ public function testParsingOptions(): void 'bar', '--foo-bar', 'yes', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -111,14 +112,14 @@ public function testParsingOptions(): void public function testParsingOptionDetails(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', 'profile', '--foo', 'bar', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -129,7 +130,7 @@ public function testParsingOptionDetails(): void public function testParsingOptionString(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', @@ -138,7 +139,7 @@ public function testParsingOptionString(): void 'bar', '--baz', 'queue some stuff', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -149,12 +150,12 @@ public function testParsingOptionString(): void public function testParsingNoOptions(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', 'profile', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -165,7 +166,7 @@ public function testParsingNoOptions(): void public function testParsingArgs(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'spark', 'command', 'param1', @@ -175,7 +176,7 @@ public function testParsingArgs(): void '--opt-2', 'opt 2 val', 'param3', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -193,14 +194,14 @@ public function testParsingArgs(): void public function testParsingPath(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', 'profile', '--foo', 'bar', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -210,7 +211,7 @@ public function testParsingPath(): void public function testParsingMalformed(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', @@ -219,7 +220,7 @@ public function testParsingMalformed(): void 'bar', '--baz', 'queue some stuff', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -231,7 +232,7 @@ public function testParsingMalformed(): void public function testParsingMalformed2(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', @@ -240,7 +241,7 @@ public function testParsingMalformed2(): void 'oops-bar', '--baz', 'queue some stuff', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -252,7 +253,7 @@ public function testParsingMalformed2(): void public function testParsingMalformed3(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'users', '21', @@ -262,7 +263,7 @@ public function testParsingMalformed3(): void 'bar', '--baz', 'queue some stuff', - ]; + ]); // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); @@ -274,8 +275,8 @@ public function testParsingMalformed3(): void public function testFetchGlobalsSingleValue(): void { - $_POST['foo'] = 'bar'; - $_GET['bar'] = 'baz'; + service('superglobals')->setPost('foo', 'bar'); + service('superglobals')->setGet('bar', 'baz'); $this->assertSame('bar', $this->request->fetchGlobal('post', 'foo')); $this->assertSame('baz', $this->request->fetchGlobal('get', 'bar')); diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index 2c19f7a7c9af..792304e8087a 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCURLRequest; use Config\App; @@ -41,6 +42,7 @@ protected function setUp(): void parent::setUp(); $this->resetServices(); + Services::injectMock('superglobals', new Superglobals()); $this->request = $this->getRequest(); } @@ -187,9 +189,9 @@ public function testOptionsHeaders(): void #[BackupGlobals(true)] public function testOptionsHeadersNotUsingPopulate(): void { - $_SERVER['HTTP_HOST'] = 'site1.com'; - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'en-US'; - $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip, deflate, br'; + service('superglobals')->setServer('HTTP_HOST', 'site1.com'); + service('superglobals')->setServer('HTTP_ACCEPT_LANGUAGE', 'en-US'); + service('superglobals')->setServer('HTTP_ACCEPT_ENCODING', 'gzip, deflate, br'); $options = [ 'baseURI' => 'http://www.foo.com/api/v1/', @@ -247,7 +249,7 @@ public function testHeaderContentLengthNotSharedBetweenRequests(): void #[BackupGlobals(true)] public function testHeaderContentLengthNotSharedBetweenClients(): void { - $_SERVER['HTTP_CONTENT_LENGTH'] = '10'; + service('superglobals')->setServer('HTTP_CONTENT_LENGTH', '10'); $options = [ 'baseURI' => 'http://www.foo.com/api/v1/', diff --git a/tests/system/HTTP/DownloadResponseTest.php b/tests/system/HTTP/DownloadResponseTest.php index 7813a08def49..d9c5e30fe207 100644 --- a/tests/system/HTTP/DownloadResponseTest.php +++ b/tests/system/HTTP/DownloadResponseTest.php @@ -13,8 +13,10 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\DownloadException; use CodeIgniter\Files\Exceptions\FileNotFoundException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use DateTime; use DateTimeZone; @@ -33,12 +35,14 @@ final class DownloadResponseTest extends CIUnitTestCase protected function setUp(): void { parent::setUp(); + + Services::injectMock('superglobals', new Superglobals()); } protected function tearDown(): void { - if (isset($_SERVER['HTTP_USER_AGENT'])) { - unset($_SERVER['HTTP_USER_AGENT']); + if (service('superglobals')->server('HTTP_USER_AGENT') !== null) { + service('superglobals')->unsetServer('HTTP_USER_AGENT'); } } @@ -286,8 +290,8 @@ public function testIfTheCharacterCodeIsOtherThanUtf8ReplaceItWithUtf8AndRawurle public function testFileExtensionIsUpperCaseWhenAndroidOSIs2(): void { - $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Linux; U; Android 2.0.3; ja-jp; SC-02C Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'; - $response = new DownloadResponse('unit-test.php', false); + service('superglobals')->setServer('HTTP_USER_AGENT', 'Mozilla/5.0 (Linux; U; Android 2.0.3; ja-jp; SC-02C Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'); + $response = new DownloadResponse('unit-test.php', false); $response->setFilePath(__FILE__); $response->buildHeaders(); diff --git a/tests/system/HTTP/Files/FileCollectionTest.php b/tests/system/HTTP/Files/FileCollectionTest.php index daebf98fc314..636285256d7b 100644 --- a/tests/system/HTTP/Files/FileCollectionTest.php +++ b/tests/system/HTTP/Files/FileCollectionTest.php @@ -26,7 +26,7 @@ final class FileCollectionTest extends CIUnitTestCase protected function setUp(): void { parent::setUp(); - $_FILES = []; + service('superglobals')->setFilesArray([]); } public function testAllReturnsArrayWithNoFiles(): void @@ -38,15 +38,15 @@ public function testAllReturnsArrayWithNoFiles(): void public function testAllReturnsValidSingleFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); $files = $collection->all(); @@ -61,7 +61,7 @@ public function testAllReturnsValidSingleFile(): void public function testAllReturnsValidMultipleFilesSameName(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => [ 'fileA.txt', @@ -72,8 +72,8 @@ public function testAllReturnsValidMultipleFilesSameName(): void 'text/csv', ], 'size' => [ - '124', - '248', + 124, + 248, ], 'tmp_name' => [ '/tmp/fileA.txt', @@ -81,7 +81,7 @@ public function testAllReturnsValidMultipleFilesSameName(): void ], 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); $files = $collection->all(); @@ -103,7 +103,7 @@ public function testAllReturnsValidMultipleFilesSameName(): void public function testAllReturnsValidMultipleFilesDifferentName(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => 'fileA.txt', 'type' => 'text/plain', @@ -118,7 +118,7 @@ public function testAllReturnsValidMultipleFilesDifferentName(): void 'tmp_name' => '/tmp/fileB.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); $files = $collection->all(); @@ -148,7 +148,7 @@ public function testAllReturnsValidMultipleFilesDifferentName(): void public function testExtensionGuessing(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => 'fileA.txt', 'type' => 'text/plain', @@ -184,7 +184,7 @@ public function testExtensionGuessing(): void 'tmp_name' => SUPPORTPATH . 'HTTP/Files/tmp/fileE.zip.rar', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -222,7 +222,7 @@ public function testExtensionGuessing(): void public function testAllReturnsValidSingleFileNestedName(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => [ 'foo' => [ @@ -246,7 +246,7 @@ public function testAllReturnsValidSingleFileNestedName(): void ], 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); $files = $collection->all(); @@ -267,15 +267,15 @@ public function testAllReturnsValidSingleFileNestedName(): void public function testHasFileWithSingleFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -285,7 +285,7 @@ public function testHasFileWithSingleFile(): void public function testHasFileWithMultipleFilesWithDifferentNames(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => 'fileA.txt', 'type' => 'text/plain', @@ -300,7 +300,7 @@ public function testHasFileWithMultipleFilesWithDifferentNames(): void 'tmp_name' => '/tmp/fileB.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -310,7 +310,7 @@ public function testHasFileWithMultipleFilesWithDifferentNames(): void public function testHasFileWithSingleFileNestedName(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => [ 'foo' => [ @@ -334,7 +334,7 @@ public function testHasFileWithSingleFileNestedName(): void ], 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -345,15 +345,15 @@ public function testHasFileWithSingleFileNestedName(): void public function testErrorString(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => UPLOAD_ERR_INI_SIZE, ], - ]; + ]); $expected = 'The file "someFile.txt" exceeds your upload_max_filesize ini directive.'; @@ -366,15 +366,15 @@ public function testErrorString(): void public function testErrorStringWithUnknownError(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 123, ], - ]; + ]); $expected = 'The file "someFile.txt" was not uploaded due to an unknown error.'; @@ -387,14 +387,14 @@ public function testErrorStringWithUnknownError(): void public function testErrorStringWithNoError(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', ], - ]; + ]); $expected = 'The file uploaded with success.'; @@ -407,15 +407,15 @@ public function testErrorStringWithNoError(): void public function testError(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => UPLOAD_ERR_INI_SIZE, ], - ]; + ]); $collection = new FileCollection(); $file = $collection->getFile('userfile'); @@ -426,14 +426,14 @@ public function testError(): void public function testErrorWithUnknownError(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', ], - ]; + ]); $collection = new FileCollection(); $file = $collection->getFile('userfile'); @@ -444,15 +444,15 @@ public function testErrorWithUnknownError(): void public function testErrorWithNoError(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); $file = $collection->getFile('userfile'); @@ -463,15 +463,15 @@ public function testErrorWithNoError(): void public function testClientPathReturnsValidFullPath(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'full_path' => 'someDir/someFile.txt', ], - ]; + ]); $collection = new FileCollection(); $file = $collection->getFile('userfile'); @@ -482,14 +482,14 @@ public function testClientPathReturnsValidFullPath(): void public function testClientPathReturnsNullWhenFullPathIsNull(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', ], - ]; + ]); $collection = new FileCollection(); $file = $collection->getFile('userfile'); @@ -500,15 +500,15 @@ public function testClientPathReturnsNullWhenFullPathIsNull(): void public function testFileReturnsValidSingleFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); $file = $collection->getFile('userfile'); @@ -520,15 +520,15 @@ public function testFileReturnsValidSingleFile(): void public function testFileNoExistSingleFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); $file = $collection->getFile('fileuser'); @@ -537,7 +537,7 @@ public function testFileNoExistSingleFile(): void public function testFileReturnValidMultipleFiles(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => [ 'fileA.txt', @@ -548,8 +548,8 @@ public function testFileReturnValidMultipleFiles(): void 'text/csv', ], 'size' => [ - '124', - '248', + 124, + 248, ], 'tmp_name' => [ '/tmp/fileA.txt', @@ -557,7 +557,7 @@ public function testFileReturnValidMultipleFiles(): void ], 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -580,7 +580,7 @@ public function testFileReturnValidMultipleFiles(): void public function testFileWithMultipleFilesNestedName(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'my-form' => [ 'name' => [ 'details' => [ @@ -623,7 +623,7 @@ public function testFileWithMultipleFilesNestedName(): void ], ], ], - ]; + ]); $collection = new FileCollection(); @@ -646,7 +646,7 @@ public function testFileWithMultipleFilesNestedName(): void public function testDoesntHaveFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'my-form' => [ 'name' => [ 'details' => [ @@ -689,7 +689,7 @@ public function testDoesntHaveFile(): void ], ], ], - ]; + ]); $collection = new FileCollection(); @@ -699,7 +699,7 @@ public function testDoesntHaveFile(): void public function testGetFileMultipleHasNoFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => [ 'fileA.txt', @@ -710,8 +710,8 @@ public function testGetFileMultipleHasNoFile(): void 'text/csv', ], 'size' => [ - '124', - '248', + 124, + 248, ], 'tmp_name' => [ '/tmp/fileA.txt', @@ -719,7 +719,7 @@ public function testGetFileMultipleHasNoFile(): void ], 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -730,7 +730,7 @@ public function testGetFileMultipleHasNoFile(): void public function testGetFileMultipleReturnValidDotNotationSyntax(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'my-form' => [ 'name' => [ 'details' => [ @@ -773,7 +773,7 @@ public function testGetFileMultipleReturnValidDotNotationSyntax(): void ], ], ], - ]; + ]); $collection = new FileCollection(); @@ -798,7 +798,7 @@ public function testGetFileMultipleReturnValidDotNotationSyntax(): void public function testGetFileMultipleReturnInvalidDotNotationSyntax(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'my-form' => [ 'name' => [ 'details' => [ @@ -826,7 +826,7 @@ public function testGetFileMultipleReturnInvalidDotNotationSyntax(): void ], ], ], - ]; + ]); $collection = new FileCollection(); @@ -836,7 +836,7 @@ public function testGetFileMultipleReturnInvalidDotNotationSyntax(): void public function testGetFileMultipleReturnValidMultipleFiles(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => [ 'fileA.txt', @@ -847,8 +847,8 @@ public function testGetFileMultipleReturnValidMultipleFiles(): void 'text/csv', ], 'size' => [ - '124', - '248', + 124, + 248, ], 'tmp_name' => [ '/tmp/fileA.txt', @@ -856,7 +856,7 @@ public function testGetFileMultipleReturnValidMultipleFiles(): void ], 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -881,15 +881,15 @@ public function testGetFileMultipleReturnValidMultipleFiles(): void public function testGetFileMultipleReturnInvalidSingleFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'fileA.txt', 'type' => 'text/csv', - 'size' => '248', + 'size' => 248, 'tmp_name' => '/tmp/fileA.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); diff --git a/tests/system/HTTP/Files/FileMovingTest.php b/tests/system/HTTP/Files/FileMovingTest.php index f0e1564db5b7..45a9fd179837 100644 --- a/tests/system/HTTP/Files/FileMovingTest.php +++ b/tests/system/HTTP/Files/FileMovingTest.php @@ -42,7 +42,7 @@ protected function setUp(): void rmdir($this->destination); } - $_FILES = []; + service('superglobals')->setFilesArray([]); // Set the mock's return value to true move_uploaded_file('', '', true); @@ -63,7 +63,7 @@ protected function tearDown(): void public function testMove(): void { $finalFilename = 'fileA'; - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => $finalFilename . '.txt', 'type' => 'text/plain', @@ -78,7 +78,7 @@ public function testMove(): void 'tmp_name' => '/tmp/fileB.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -103,7 +103,7 @@ public function testMove(): void public function testMoveOverwriting(): void { $finalFilename = 'file_with_delimiters_underscore'; - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => $finalFilename . '.txt', 'type' => 'text/plain', @@ -125,7 +125,7 @@ public function testMoveOverwriting(): void 'tmp_name' => '/tmp/fileC.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -153,7 +153,7 @@ public function testMoveOverwriting(): void public function testMoved(): void { $finalFilename = 'fileA'; - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => $finalFilename . '.txt', 'type' => 'text/plain', @@ -161,7 +161,7 @@ public function testMoved(): void 'tmp_name' => '/tmp/fileA.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -186,7 +186,7 @@ public function testMoved(): void public function testStore(): void { $finalFilename = 'fileA'; - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => $finalFilename . '.txt', 'type' => 'text/plain', @@ -194,7 +194,7 @@ public function testStore(): void 'tmp_name' => '/tmp/fileA.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -218,7 +218,7 @@ public function testStore(): void public function testAlreadyMoved(): void { $finalFilename = 'fileA'; - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => $finalFilename . '.txt', 'type' => 'text/plain', @@ -226,7 +226,7 @@ public function testAlreadyMoved(): void 'tmp_name' => '/tmp/fileA.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); @@ -248,15 +248,15 @@ public function testAlreadyMoved(): void public function testInvalidFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => UPLOAD_ERR_INI_SIZE, ], - ]; + ]); $destination = $this->destination; $collection = new FileCollection(); @@ -270,15 +270,15 @@ public function testInvalidFile(): void public function testFailedMoveBecauseOfWarning(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $destination = $this->destination; // Create the destination and make it read only @@ -297,7 +297,7 @@ public function testFailedMoveBecauseOfWarning(): void public function testFailedMoveBecauseOfFalseReturned(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile1' => [ 'name' => 'fileA.txt', 'type' => 'text/plain', @@ -305,7 +305,7 @@ public function testFailedMoveBecauseOfFalseReturned(): void 'tmp_name' => '/tmp/fileA.txt', 'error' => 0, ], - ]; + ]); $collection = new FileCollection(); diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 7829786f7f4e..509cf69643d2 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -14,10 +14,12 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\Files\UploadedFile; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use JsonException; @@ -43,10 +45,11 @@ protected function setUp(): void { parent::setUp(); + $_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = []; + Services::injectMock('superglobals', new Superglobals()); + $config = new App(); $this->request = $this->createRequest($config); - - $_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = []; } private function createRequest(?App $config = null, $body = null, ?string $path = null): IncomingRequest @@ -61,7 +64,7 @@ private function createRequest(?App $config = null, $body = null, ?string $path public function testCanGrabRequestVars(): void { - $_REQUEST['TEST'] = 5; + service('superglobals')->setRequest('TEST', '5'); $this->assertSame('5', $this->request->getVar('TEST')); $this->assertNull($this->request->getVar('TESTY')); @@ -69,7 +72,7 @@ public function testCanGrabRequestVars(): void public function testCanGrabGetVars(): void { - $_GET['TEST'] = 5; + service('superglobals')->setGet('TEST', '5'); $this->assertSame('5', $this->request->getGet('TEST')); $this->assertNull($this->request->getGet('TESTY')); @@ -77,7 +80,7 @@ public function testCanGrabGetVars(): void public function testCanGrabPostVars(): void { - $_POST['TEST'] = 5; + service('superglobals')->setPost('TEST', '5'); $this->assertSame('5', $this->request->getPost('TEST')); $this->assertNull($this->request->getPost('TESTY')); @@ -85,8 +88,8 @@ public function testCanGrabPostVars(): void public function testCanGrabPostBeforeGet(): void { - $_POST['TEST'] = 5; - $_GET['TEST'] = 3; + service('superglobals')->setPost('TEST', '5'); + service('superglobals')->setGet('TEST', '3'); $this->assertSame('5', $this->request->getPostGet('TEST')); $this->assertSame('3', $this->request->getGetPost('TEST')); @@ -183,7 +186,7 @@ public function testCanGrabEnvVars(): void public function testCanGrabCookieVars(): void { - $_COOKIE['TEST'] = 5; + service('superglobals')->setCookie('TEST', '5'); $this->assertSame('5', $this->request->getCookie('TEST')); $this->assertNull($this->request->getCookie('TESTY')); @@ -243,7 +246,7 @@ public function testSetValidLocales(): void */ public function testNegotiatesLocale(): void { - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr-FR; q=1.0, en; q=0.5'; + service('superglobals')->setServer('HTTP_ACCEPT_LANGUAGE', 'fr-FR); q=1.0, en; q=0.5'); $config = new App(); $config->negotiateLocale = true; @@ -258,7 +261,7 @@ public function testNegotiatesLocale(): void public function testNegotiatesLocaleOnlyBroad(): void { - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr; q=1.0, en; q=0.5'; + service('superglobals')->setServer('HTTP_ACCEPT_LANGUAGE', 'fr); q=1.0, en; q=0.5'); $config = new App(); $config->negotiateLocale = true; @@ -285,7 +288,7 @@ public function testNegotiatesNot(): void public function testNegotiatesCharset(): void { - // $_SERVER['HTTP_ACCEPT_CHARSET'] = 'iso-8859-5, unicode-1-1;q=0.8'; + // service('superglobals')->setServer('HTTP_ACCEPT_CHARSET', 'iso-8859-5, unicode-1-1);q=0.8'; $this->request->setHeader('Accept-Charset', 'iso-8859-5, unicode-1-1;q=0.8'); $this->assertSame( @@ -488,8 +491,8 @@ public function testGetVarWorksWithJsonAndGetParams(): void $config->baseURL = 'http://example.com/'; // GET method - $_REQUEST['foo'] = 'bar'; - $_REQUEST['fizz'] = 'buzz'; + service('superglobals')->setRequest('foo', 'bar'); + service('superglobals')->setRequest('fizz', 'buzz'); $request = $this->createRequest($config); $request = $request->withMethod('GET'); @@ -749,7 +752,7 @@ public function testIsAJAX(): void public function testIsSecure(): void { - $_SERVER['HTTPS'] = 'on'; + service('superglobals')->setServer('HTTPS', 'on'); $this->assertTrue($this->request->isSecure()); } @@ -777,15 +780,15 @@ public function testUserAgent(): void public function testFileCollectionFactory(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $files = $this->request->getFiles(); $this->assertCount(1, $files); @@ -799,7 +802,7 @@ public function testFileCollectionFactory(): void public function testGetFileMultiple(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => [ 'someFile.txt', @@ -810,8 +813,8 @@ public function testGetFileMultiple(): void 'text/plain', ], 'size' => [ - '124', - '125', + 124, + 125, ], 'tmp_name' => [ '/tmp/myTempFile.txt', @@ -822,7 +825,7 @@ public function testGetFileMultiple(): void 0, ], ], - ]; + ]); $gotit = $this->request->getFileMultiple('userfile'); $this->assertSame(124, $gotit[0]->getSize()); @@ -831,15 +834,15 @@ public function testGetFileMultiple(): void public function testGetFile(): void { - $_FILES = [ + service('superglobals')->setFilesArray([ 'userfile' => [ 'name' => 'someFile.txt', 'type' => 'text/plain', - 'size' => '124', + 'size' => 124, 'tmp_name' => '/tmp/myTempFile.txt', 'error' => 0, ], - ]; + ]); $gotit = $this->request->getFile('userfile'); $this->assertSame(124, $gotit->getSize()); @@ -856,30 +859,36 @@ public function testSpoofing(): void */ public function testGetPostEmpty(): void { - $_POST['TEST'] = '5'; - $_GET['TEST'] = '3'; - $this->assertSame($_POST, $this->request->getPostGet()); - $this->assertSame($_GET, $this->request->getGetPost()); + service('superglobals') + ->setPost('TEST', '5') + ->setGet('TEST', '3'); + + $this->assertSame(['TEST' => '5'], $this->request->getPostGet()); + $this->assertSame(['TEST' => '3'], $this->request->getGetPost()); } public function testPostGetSecondStream(): void { - $_GET['get'] = '3'; - $this->assertSame($_GET, $this->request->getPostGet()); + service('superglobals')->setGet('get', '3'); + + $this->assertSame(['get' => '3'], $this->request->getPostGet()); } public function testGetPostSecondStream(): void { - $_POST['post'] = '5'; - $this->assertSame($_POST, $this->request->getGetPost()); + service('superglobals')->setPost('post', '5'); + + $this->assertSame(['post' => '5'], $this->request->getGetPost()); } public function testGetPostSecondStreams(): void { - $_GET['get'] = '3'; - $_POST['post'] = '5'; - $this->assertSame(array_merge($_GET, $_POST), $this->request->getPostGet()); - $this->assertSame(array_merge($_POST, $_GET), $this->request->getGetPost()); + service('superglobals') + ->setGet('get', '3') + ->setPost('post', '5'); + + $this->assertSame(['get' => '3', 'post' => '5'], $this->request->getPostGet()); + $this->assertSame(['post' => '5', 'get' => '3'], $this->request->getGetPost()); } public function testGetBodyWithFalseBody(): void @@ -903,8 +912,9 @@ public function testGetBodyWithZero(): void */ public function testGetPostIndexNotExists(): void { - $_POST['TEST'] = 5; - $_GET['TEST'] = 3; + service('superglobals') + ->setPost('TEST', '5') + ->setGet('TEST', '3'); $this->assertNull($this->request->getPostGet('gc')); $this->assertNull($this->request->getGetPost('gc')); } @@ -929,8 +939,8 @@ public function testSetPath(): void public function testGetIPAddressNormal(): void { - $expected = '123.123.123.123'; - $_SERVER['REMOTE_ADDR'] = $expected; + $expected = '123.123.123.123'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); $this->request = new Request(new App()); $this->request->populateHeaders(); @@ -942,9 +952,10 @@ public function testGetIPAddressNormal(): void public function testGetIPAddressThruProxy(): void { - $expected = '123.123.123.123'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; - $_SERVER['REMOTE_ADDR'] = '10.0.1.200'; + $expected = '123.123.123.123'; + service('superglobals') + ->setServer('HTTP_X_FORWARDED_FOR', $expected) + ->setServer('REMOTE_ADDR', '10.0.1.200'); $config = new App(); $config->proxyIPs = [ @@ -961,9 +972,10 @@ public function testGetIPAddressThruProxy(): void public function testGetIPAddressThruProxyIPv6(): void { - $expected = '123.123.123.123'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; - $_SERVER['REMOTE_ADDR'] = '2001:db8::2:1'; + $expected = '123.123.123.123'; + service('superglobals') + ->setServer('HTTP_X_FORWARDED_FOR', $expected) + ->setServer('REMOTE_ADDR', '2001:db8::2:1'); $config = new App(); $config->proxyIPs = [ @@ -979,9 +991,9 @@ public function testGetIPAddressThruProxyIPv6(): void public function testGetIPAddressThruProxyInvalidIPAddress(): void { - $_SERVER['HTTP_X_FORWARDED_FOR'] = '123.456.23.123'; - $expected = '10.0.1.200'; - $_SERVER['REMOTE_ADDR'] = $expected; + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', '123.456.23.123'); + $expected = '10.0.1.200'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); $config = new App(); $config->proxyIPs = [ @@ -997,9 +1009,9 @@ public function testGetIPAddressThruProxyInvalidIPAddress(): void public function testGetIPAddressThruProxyInvalidIPAddressIPv6(): void { - $_SERVER['HTTP_X_FORWARDED_FOR'] = '2001:xyz::1'; - $expected = '2001:db8::2:1'; - $_SERVER['REMOTE_ADDR'] = $expected; + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', '2001:xyz::1'); + $expected = '2001:db8::2:1'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); $config = new App(); $config->proxyIPs = [ @@ -1014,9 +1026,9 @@ public function testGetIPAddressThruProxyInvalidIPAddressIPv6(): void public function testGetIPAddressThruProxyNotWhitelisted(): void { - $expected = '10.10.1.200'; - $_SERVER['REMOTE_ADDR'] = $expected; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '123.456.23.123'; + $expected = '10.10.1.200'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', '123.456.23.123'); $config = new App(); $config->proxyIPs = [ @@ -1032,9 +1044,9 @@ public function testGetIPAddressThruProxyNotWhitelisted(): void public function testGetIPAddressThruProxyNotWhitelistedIPv6(): void { - $expected = '2001:db8::2:2'; - $_SERVER['REMOTE_ADDR'] = $expected; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '123.456.23.123'; + $expected = '2001:db8::2:2'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', '123.456.23.123'); $config = new App(); $config->proxyIPs = [ @@ -1049,9 +1061,9 @@ public function testGetIPAddressThruProxyNotWhitelistedIPv6(): void public function testGetIPAddressThruProxySubnet(): void { - $expected = '123.123.123.123'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; - $_SERVER['REMOTE_ADDR'] = '192.168.5.21'; + $expected = '123.123.123.123'; + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', $expected); + service('superglobals')->setServer('REMOTE_ADDR', '192.168.5.21'); $config = new App(); $config->proxyIPs = ['192.168.5.0/24' => 'X-Forwarded-For']; @@ -1065,9 +1077,9 @@ public function testGetIPAddressThruProxySubnet(): void public function testGetIPAddressThruProxySubnetIPv6(): void { - $expected = '123.123.123.123'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; - $_SERVER['REMOTE_ADDR'] = '2001:db8:1234:ffff:ffff:ffff:ffff:ffff'; + $expected = '123.123.123.123'; + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', $expected); + service('superglobals')->setServer('REMOTE_ADDR', '2001:db8:1234:ffff:ffff:ffff:ffff:ffff'); $config = new App(); $config->proxyIPs = ['2001:db8:1234::/48' => 'X-Forwarded-For']; @@ -1081,9 +1093,9 @@ public function testGetIPAddressThruProxySubnetIPv6(): void public function testGetIPAddressThruProxyOutOfSubnet(): void { - $expected = '192.168.5.21'; - $_SERVER['REMOTE_ADDR'] = $expected; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '123.123.123.123'; + $expected = '192.168.5.21'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', '123.123.123.123'); $config = new App(); $config->proxyIPs = ['192.168.5.0/28' => 'X-Forwarded-For']; @@ -1096,9 +1108,9 @@ public function testGetIPAddressThruProxyOutOfSubnet(): void public function testGetIPAddressThruProxyOutOfSubnetIPv6(): void { - $expected = '2001:db8:1235:ffff:ffff:ffff:ffff:ffff'; - $_SERVER['REMOTE_ADDR'] = $expected; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '123.123.123.123'; + $expected = '2001:db8:1235:ffff:ffff:ffff:ffff:ffff'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', '123.123.123.123'); $config = new App(); $config->proxyIPs = ['2001:db8:1234::/48' => 'X-Forwarded-For']; @@ -1111,9 +1123,9 @@ public function testGetIPAddressThruProxyOutOfSubnetIPv6(): void public function testGetIPAddressThruProxyBothIPv4AndIPv6(): void { - $expected = '2001:db8:1235:ffff:ffff:ffff:ffff:ffff'; - $_SERVER['REMOTE_ADDR'] = $expected; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '123.123.123.123'; + $expected = '2001:db8:1235:ffff:ffff:ffff:ffff:ffff'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); + service('superglobals')->setServer('HTTP_X_FORWARDED_FOR', '123.123.123.123'); $config = new App(); $config->proxyIPs = [ diff --git a/tests/system/HTTP/MessageTest.php b/tests/system/HTTP/MessageTest.php index 28483674fee9..7a691af39c08 100644 --- a/tests/system/HTTP/MessageTest.php +++ b/tests/system/HTTP/MessageTest.php @@ -13,15 +13,19 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[BackupGlobals(true)] #[Group('Others')] final class MessageTest extends CIUnitTestCase { @@ -31,6 +35,8 @@ protected function setUp(): void { parent::setUp(); + Services::injectMock('superglobals', new Superglobals()); + $this->message = new Message(); } @@ -242,11 +248,11 @@ public function testSetHeaderWithExistingArrayValuesAppendNullValue(): void public function testPopulateHeadersWithoutContentType(): void { - $original = $_SERVER; - $originalEnv = getenv('CONTENT_TYPE'); + $superglobals = service('superglobals'); + $originalEnv = getenv('CONTENT_TYPE'); // fail path, if the CONTENT_TYPE doesn't exist - $_SERVER = ['HTTP_ACCEPT_LANGUAGE' => 'en-us,en;q=0.50']; + $superglobals->setServerArray(['HTTP_ACCEPT_LANGUAGE' => 'en-us,en;q=0.50']); putenv('CONTENT_TYPE'); $this->message->populateHeaders(); @@ -254,57 +260,50 @@ public function testPopulateHeadersWithoutContentType(): void $this->assertNull($this->message->header('content-type')); putenv("CONTENT_TYPE={$originalEnv}"); - $_SERVER = $original; // restore so code coverage doesn't break } public function testPopulateHeadersWithoutHTTP(): void { // fail path, if argument doesn't have the HTTP_* - $original = $_SERVER; - $_SERVER = [ + $superglobals = service('superglobals'); + $superglobals->setServerArray([ 'USER_AGENT' => 'Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405', 'REQUEST_METHOD' => 'POST', - ]; + ]); $this->message->populateHeaders(); $this->assertNull($this->message->header('user-agent')); $this->assertNull($this->message->header('request-method')); - - $_SERVER = $original; // restore so code coverage doesn't break } public function testPopulateHeadersKeyNotExists(): void { // Success path, if array key is not exists, assign empty string to it's value - $original = $_SERVER; - $_SERVER = [ + $superglobals = service('superglobals'); + $superglobals->setServerArray([ 'CONTENT_TYPE' => 'text/html; charset=utf-8', 'HTTP_ACCEPT_CHARSET' => null, - ]; + ]); $this->message->populateHeaders(); $this->assertSame('', $this->message->header('accept-charset')->getValue()); - - $_SERVER = $original; // restore so code coverage doesn't break } public function testPopulateHeaders(): void { // success path - $original = $_SERVER; - $_SERVER = [ + $superglobals = service('superglobals'); + $superglobals->setServerArray([ 'CONTENT_TYPE' => 'text/html; charset=utf-8', 'HTTP_ACCEPT_LANGUAGE' => 'en-us,en;q=0.50', - ]; + ]); $this->message->populateHeaders(); $this->assertSame('text/html; charset=utf-8', $this->message->header('content-type')->getValue()); $this->assertSame('en-us,en;q=0.50', $this->message->header('accept-language')->getValue()); - - $_SERVER = $original; // restore so code coverage doesn't break } public function testAddHeaderAddsFirstHeader(): void diff --git a/tests/system/HTTP/RedirectResponseTest.php b/tests/system/HTTP/RedirectResponseTest.php index d6203aa935fd..2ea700c03d88 100644 --- a/tests/system/HTTP/RedirectResponseTest.php +++ b/tests/system/HTTP/RedirectResponseTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Config\Factories; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\Router\RouteCollection; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockIncomingRequest; use CodeIgniter\Validation\Validation; @@ -49,7 +50,8 @@ protected function setUp(): void $this->resetServices(); - $_SERVER['REQUEST_METHOD'] = 'GET'; + Services::injectMock('superglobals', new Superglobals()); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->config = new App(); $this->config->baseURL = 'http://example.com/'; @@ -134,8 +136,8 @@ public function testRedirectRelativeConvertsToFullURI(): void public function testWithInput(): void { $_SESSION = []; - $_GET = ['foo' => 'bar']; - $_POST = ['bar' => 'baz']; + service('superglobals')->setGet('foo', 'bar'); + service('superglobals')->setPost('bar', 'baz'); $response = new RedirectResponse(new App()); @@ -183,7 +185,7 @@ public function testWith(): void #[RunInSeparateProcess] public function testRedirectBack(): void { - $_SERVER['HTTP_REFERER'] = 'http://somewhere.com'; + service('superglobals')->setServer('HTTP_REFERER', 'http://somewhere.com'); $this->request = new MockIncomingRequest($this->config, new SiteURI($this->config), null, new UserAgent()); Services::injectMock('request', $this->request); diff --git a/tests/system/HTTP/RequestTest.php b/tests/system/HTTP/RequestTest.php index 6659c6966861..0ccaf8083f4f 100644 --- a/tests/system/HTTP/RequestTest.php +++ b/tests/system/HTTP/RequestTest.php @@ -14,6 +14,8 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use PHPUnit\Framework\Attributes\BackupGlobals; @@ -32,16 +34,16 @@ protected function setUp(): void { parent::setUp(); - $this->request = new Request(new App()); + Services::injectMock('superglobals', new Superglobals(null, [], [])); - $_POST = []; - $_GET = []; + $this->request = new Request(new App()); } public function testFetchGlobalsSingleValue(): void { - $_POST['foo'] = 'bar'; - $_GET['bar'] = 'baz'; + service('superglobals') + ->setPost('foo', 'bar') + ->setGet('bar', 'baz'); $this->assertSame('bar', $this->request->fetchGlobal('post', 'foo')); $this->assertSame('baz', $this->request->fetchGlobal('get', 'bar')); @@ -557,9 +559,9 @@ public function testGetIPAddressDefault(): void public function testGetIPAddressNormal(): void { - $expected = '123.123.123.123'; - $_SERVER['REMOTE_ADDR'] = $expected; - $this->request = new Request(new App()); + $expected = '123.123.123.123'; + service('superglobals')->setServer('REMOTE_ADDR', $expected); + $this->request = new Request(new App()); $this->assertSame($expected, $this->request->getIPAddress()); // call a second time to exercise the initial conditional block in getIPAddress() $this->assertSame($expected, $this->request->getIPAddress()); @@ -567,9 +569,10 @@ public function testGetIPAddressNormal(): void public function testGetIPAddressThruProxy(): void { - $expected = '123.123.123.123'; - $_SERVER['REMOTE_ADDR'] = '10.0.1.200'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; + $expected = '123.123.123.123'; + service('superglobals') + ->setServer('REMOTE_ADDR', '10.0.1.200') + ->setServer('HTTP_X_FORWARDED_FOR', $expected); $config = new App(); $config->proxyIPs = [ @@ -586,11 +589,12 @@ public function testGetIPAddressThruProxy(): void public function testGetIPAddressThruProxyInvalid(): void { - $expected = '123.456.23.123'; - $_SERVER['REMOTE_ADDR'] = '10.0.1.200'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; - $config = new App(); - $config->proxyIPs = [ + $expected = '123.456.23.123'; + service('superglobals') + ->setServer('REMOTE_ADDR', '10.0.1.200') + ->setServer('HTTP_X_FORWARDED_FOR', $expected); + $config = new App(); + $config->proxyIPs = [ '10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For', ]; @@ -604,9 +608,10 @@ public function testGetIPAddressThruProxyInvalid(): void public function testGetIPAddressThruProxyNotWhitelisted(): void { - $expected = '123.456.23.123'; - $_SERVER['REMOTE_ADDR'] = '10.10.1.200'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; + $expected = '123.456.23.123'; + service('superglobals') + ->setServer('REMOTE_ADDR', '10.10.1.200') + ->setServer('HTTP_X_FORWARDED_FOR', $expected); $config = new App(); $config->proxyIPs = [ @@ -622,9 +627,10 @@ public function testGetIPAddressThruProxyNotWhitelisted(): void public function testGetIPAddressThruProxySubnet(): void { - $expected = '123.123.123.123'; - $_SERVER['REMOTE_ADDR'] = '192.168.5.21'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; + $expected = '123.123.123.123'; + service('superglobals') + ->setServer('REMOTE_ADDR', '192.168.5.21') + ->setServer('HTTP_X_FORWARDED_FOR', $expected); $config = new App(); $config->proxyIPs = ['192.168.5.0/24' => 'X-Forwarded-For']; @@ -638,9 +644,10 @@ public function testGetIPAddressThruProxySubnet(): void public function testGetIPAddressThruProxyOutofSubnet(): void { - $expected = '123.123.123.123'; - $_SERVER['REMOTE_ADDR'] = '192.168.5.21'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = $expected; + $expected = '123.123.123.123'; + service('superglobals') + ->setServer('REMOTE_ADDR', '192.168.5.21') + ->setServer('HTTP_X_FORWARDED_FOR', $expected); $config = new App(); $config->proxyIPs = ['192.168.5.0/28' => 'X-Forwarded-For']; diff --git a/tests/system/HTTP/ResponseTest.php b/tests/system/HTTP/ResponseTest.php index bea0944599d8..84408df0e5a5 100644 --- a/tests/system/HTTP/ResponseTest.php +++ b/tests/system/HTTP/ResponseTest.php @@ -14,7 +14,9 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockResponse; use Config\App; @@ -33,7 +35,8 @@ final class ResponseTest extends CIUnitTestCase protected function setUp(): void { - $this->server = $_SERVER; + Services::injectMock('superglobals', new Superglobals()); + $this->server = service('superglobals')->getServerArray(); parent::setUp(); @@ -44,7 +47,7 @@ protected function tearDown(): void { Factories::reset('config'); - $_SERVER = $this->server; + service('superglobals')->setServerArray($this->server); } public function testCanSetStatusCode(): void @@ -278,9 +281,10 @@ public function testRedirect( ?int $code, int $expectedCode, ): void { - $_SERVER['SERVER_SOFTWARE'] = $server; - $_SERVER['SERVER_PROTOCOL'] = $protocol; - $_SERVER['REQUEST_METHOD'] = $method; + service('superglobals') + ->setServer('SERVER_SOFTWARE', $server) + ->setServer('SERVER_PROTOCOL', $protocol) + ->setServer('REQUEST_METHOD', $method); $response = new Response(new App()); $response->redirect('example.com', 'auto', $code); @@ -321,9 +325,10 @@ public function testRedirectWithIIS( ?int $code, int $expectedCode, ): void { - $_SERVER['SERVER_SOFTWARE'] = 'Microsoft-IIS'; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals') + ->setServer('SERVER_SOFTWARE', 'Microsoft-IIS') + ->setServer('SERVER_PROTOCOL', 'HTTP/1.1') + ->setServer('REQUEST_METHOD', 'POST'); $response = new Response(new App()); $response->redirect('example.com', 'auto', $code); @@ -331,7 +336,7 @@ public function testRedirectWithIIS( $this->assertSame('0;url=example.com', $response->getHeaderLine('Refresh')); $this->assertSame($expectedCode, $response->getStatusCode()); - unset($_SERVER['SERVER_SOFTWARE']); + service('superglobals')->unsetServer('SERVER_SOFTWARE'); } public static function provideRedirectWithIIS(): iterable @@ -518,9 +523,10 @@ public function testMisbehaving(): void public function testTemporaryRedirectHTTP11(): void { - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - $response = new Response(new App()); + service('superglobals') + ->setServer('SERVER_PROTOCOL', 'HTTP/1.1') + ->setServer('REQUEST_METHOD', 'POST'); + $response = new Response(new App()); $response->setProtocolVersion('HTTP/1.1'); $response->redirect('/foo'); @@ -530,9 +536,10 @@ public function testTemporaryRedirectHTTP11(): void public function testTemporaryRedirectGetHTTP11(): void { - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; - $response = new Response(new App()); + service('superglobals') + ->setServer('SERVER_PROTOCOL', 'HTTP/1.1') + ->setServer('REQUEST_METHOD', 'GET'); + $response = new Response(new App()); $response->setProtocolVersion('HTTP/1.1'); $response->redirect('/foo'); diff --git a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php index cbd309dffa7c..2a0e485775d4 100644 --- a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php +++ b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; @@ -32,14 +33,15 @@ protected function setUp(): void parent::setUp(); $_GET = $_SERVER = []; + + Services::injectMock('superglobals', new Superglobals()); } private function createSiteURIFactory(array $server, ?App $appConfig = null): SiteURIFactory { $appConfig ??= new App(); - $_SERVER = $server; - $superglobals = new Superglobals(); + $superglobals = new Superglobals($server); return new SiteURIFactory($appConfig, $superglobals); } @@ -47,10 +49,11 @@ private function createSiteURIFactory(array $server, ?App $appConfig = null): Si public function testDefault(): void { // /index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/woot') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'woot'; $this->assertSame($expected, $factory->detectRoutePath()); @@ -59,10 +62,11 @@ public function testDefault(): void public function testDefaultEmpty(): void { // / - $_SERVER['REQUEST_URI'] = '/'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = '/'; $this->assertSame($expected, $factory->detectRoutePath()); @@ -71,10 +75,11 @@ public function testDefaultEmpty(): void public function testRequestURI(): void { // /index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/woot') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'woot'; $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); @@ -91,10 +96,11 @@ public function testRequestURINested(): void // So I don't remove this test case. // /ci/index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/woot') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'woot'; $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); @@ -103,10 +109,11 @@ public function testRequestURINested(): void public function testRequestURISubfolder(): void { // /ci/index.php/popcorn/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/ci/index.php/popcorn/woot'; - $_SERVER['SCRIPT_NAME'] = '/ci/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/ci/index.php/popcorn/woot') + ->setServer('SCRIPT_NAME', '/ci/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'popcorn/woot'; $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); @@ -115,10 +122,11 @@ public function testRequestURISubfolder(): void public function testRequestURINoIndex(): void { // /sub/example - $_SERVER['REQUEST_URI'] = '/sub/example'; - $_SERVER['SCRIPT_NAME'] = '/sub/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/sub/example') + ->setServer('SCRIPT_NAME', '/sub/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'example'; $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); @@ -127,10 +135,11 @@ public function testRequestURINoIndex(): void public function testRequestURINginx(): void { // /ci/index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/woot?code=good') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'woot'; $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); @@ -139,10 +148,11 @@ public function testRequestURINginx(): void public function testRequestURINginxRedirecting(): void { // /?/ci/index.php/woot - $_SERVER['REQUEST_URI'] = '/?/ci/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/?/ci/woot') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'ci/woot'; $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); @@ -151,10 +161,11 @@ public function testRequestURINginxRedirecting(): void public function testRequestURISuppressed(): void { // /woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/woot'; - $_SERVER['SCRIPT_NAME'] = '/'; + service('superglobals') + ->setServer('REQUEST_URI', '/woot') + ->setServer('SCRIPT_NAME', '/'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'woot'; $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); @@ -163,10 +174,11 @@ public function testRequestURISuppressed(): void public function testRequestURIGetPath(): void { // /index.php/fruits/banana - $_SERVER['REQUEST_URI'] = '/index.php/fruits/banana'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/fruits/banana') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); } @@ -174,10 +186,11 @@ public function testRequestURIGetPath(): void public function testRequestURIPathIsRelative(): void { // /sub/folder/index.php/fruits/banana - $_SERVER['REQUEST_URI'] = '/sub/folder/index.php/fruits/banana'; - $_SERVER['SCRIPT_NAME'] = '/sub/folder/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/sub/folder/index.php/fruits/banana') + ->setServer('SCRIPT_NAME', '/sub/folder/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); } @@ -185,24 +198,26 @@ public function testRequestURIPathIsRelative(): void public function testRequestURIStoresDetectedPath(): void { // /fruits/banana - $_SERVER['REQUEST_URI'] = '/fruits/banana'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/fruits/banana') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); - $_SERVER['REQUEST_URI'] = '/candy/snickers'; + service('superglobals')->setServer('REQUEST_URI', '/candy/snickers'); $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); } public function testRequestURIPathIsNeverRediscovered(): void { - $_SERVER['REQUEST_URI'] = '/fruits/banana'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/fruits/banana') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); - $_SERVER['REQUEST_URI'] = '/candy/snickers'; + service('superglobals')->setServer('REQUEST_URI', '/candy/snickers'); $factory->detectRoutePath('REQUEST_URI'); $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); @@ -211,13 +226,13 @@ public function testRequestURIPathIsNeverRediscovered(): void public function testQueryString(): void { // /index.php?/ci/woot - $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot'; - $_SERVER['QUERY_STRING'] = '/ci/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $_GET['/ci/woot'] = ''; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php?/ci/woot') + ->setServer('QUERY_STRING', '/ci/woot') + ->setServer('SCRIPT_NAME', '/index.php') + ->setGet('/ci/woot', ''); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'ci/woot'; $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); @@ -226,13 +241,13 @@ public function testQueryString(): void public function testQueryStringWithQueryString(): void { // /index.php?/ci/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot?code=good'; - $_SERVER['QUERY_STRING'] = '/ci/woot?code=good'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $_GET['/ci/woot?code'] = 'good'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php?/ci/woot?code=good') + ->setServer('QUERY_STRING', '/ci/woot?code=good') + ->setServer('SCRIPT_NAME', '/index.php') + ->setGet('/ci/woot?code', 'good'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'ci/woot'; $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); @@ -243,10 +258,11 @@ public function testQueryStringWithQueryString(): void public function testQueryStringEmpty(): void { // /index.php? - $_SERVER['REQUEST_URI'] = '/index.php?'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php?') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = '/'; $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); @@ -255,10 +271,11 @@ public function testQueryStringEmpty(): void public function testPathInfoUnset(): void { // /index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/woot') + ->setServer('SCRIPT_NAME', '/index.php'); - $factory = $this->createSiteURIFactory($_SERVER); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray()); $expected = 'woot'; $this->assertSame($expected, $factory->detectRoutePath('PATH_INFO')); @@ -270,11 +287,12 @@ public function testPathInfoSubfolder(): void $appConfig->baseURL = 'http://localhost:8888/ci431/public/'; // http://localhost:8888/ci431/public/index.php/woot?code=good#pos - $_SERVER['PATH_INFO'] = '/woot'; - $_SERVER['REQUEST_URI'] = '/ci431/public/index.php/woot?code=good'; - $_SERVER['SCRIPT_NAME'] = '/ci431/public/index.php'; + service('superglobals') + ->setServer('PATH_INFO', '/woot') + ->setServer('REQUEST_URI', '/ci431/public/index.php/woot?code=good') + ->setServer('SCRIPT_NAME', '/ci431/public/index.php'); - $factory = $this->createSiteURIFactory($_SERVER, $appConfig); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray(), $appConfig); $expected = 'woot'; $this->assertSame($expected, $factory->detectRoutePath('PATH_INFO')); @@ -290,10 +308,10 @@ public function testExtensionPHP($path, $detectPath): void $config = new App(); $config->baseURL = 'http://example.com/'; - $_SERVER['REQUEST_URI'] = $path; - $_SERVER['SCRIPT_NAME'] = $path; + service('superglobals')->setServer('REQUEST_URI', $path); + service('superglobals')->setServer('SCRIPT_NAME', $path); - $factory = $this->createSiteURIFactory($_SERVER, $config); + $factory = $this->createSiteURIFactory(service('superglobals')->getServerArray(), $config); $this->assertSame($detectPath, $factory->detectRoutePath()); } diff --git a/tests/system/HTTP/SiteURIFactoryTest.php b/tests/system/HTTP/SiteURIFactoryTest.php index b26fde04b245..8057c7826d62 100644 --- a/tests/system/HTTP/SiteURIFactoryTest.php +++ b/tests/system/HTTP/SiteURIFactoryTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; @@ -31,7 +32,7 @@ protected function setUp(): void { parent::setUp(); - $_GET = $_SERVER = []; + Services::injectMock('superglobals', new Superglobals([], [])); } private function createSiteURIFactory(?App $config = null, ?Superglobals $superglobals = null): SiteURIFactory @@ -45,13 +46,13 @@ private function createSiteURIFactory(?App $config = null, ?Superglobals $superg public function testCreateFromGlobals(): void { // http://localhost:8080/index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['QUERY_STRING'] = 'code=good'; - $_SERVER['HTTP_HOST'] = 'localhost:8080'; - $_SERVER['PATH_INFO'] = '/woot'; - - $_GET['code'] = 'good'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/woot?code=good') + ->setServer('SCRIPT_NAME', '/index.php') + ->setServer('QUERY_STRING', 'code=good') + ->setServer('HTTP_HOST', 'localhost:8080') + ->setServer('PATH_INFO', '/woot') + ->setGet('code', 'good'); $factory = $this->createSiteURIFactory(); @@ -66,13 +67,13 @@ public function testCreateFromGlobals(): void public function testCreateFromGlobalsAllowedHost(): void { // http://users.example.jp/index.php/woot?code=good#pos - $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_SERVER['QUERY_STRING'] = 'code=good'; - $_SERVER['HTTP_HOST'] = 'users.example.jp'; - $_SERVER['PATH_INFO'] = '/woot'; - - $_GET['code'] = 'good'; + service('superglobals') + ->setServer('REQUEST_URI', '/index.php/woot?code=good') + ->setServer('SCRIPT_NAME', '/index.php') + ->setServer('QUERY_STRING', 'code=good') + ->setServer('HTTP_HOST', 'users.example.jp') + ->setServer('PATH_INFO', '/woot') + ->setGet('code', 'good'); $config = new App(); $config->baseURL = 'http://example.jp/'; diff --git a/tests/system/HTTP/URITest.php b/tests/system/HTTP/URITest.php index 3199d9c1cb25..c449d13af65c 100644 --- a/tests/system/HTTP/URITest.php +++ b/tests/system/HTTP/URITest.php @@ -1049,11 +1049,11 @@ public function testSetBadSegmentSilent(): void public function testBasedNoIndex(): void { - $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; - $_SERVER['SCRIPT_NAME'] = '/ci/v4/index.php'; - $_SERVER['QUERY_STRING'] = ''; - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['PATH_INFO'] = '/controller/method'; + service('superglobals')->setServer('REQUEST_URI', '/ci/v4/controller/method'); + service('superglobals')->setServer('SCRIPT_NAME', '/ci/v4/index.php'); + service('superglobals')->setServer('QUERY_STRING', ''); + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('PATH_INFO', '/controller/method'); $this->resetServices(); @@ -1083,11 +1083,11 @@ public function testBasedNoIndex(): void public function testBasedWithIndex(): void { - $_SERVER['REQUEST_URI'] = '/ci/v4/index.php/controller/method'; - $_SERVER['SCRIPT_NAME'] = '/ci/v4/index.php'; - $_SERVER['QUERY_STRING'] = ''; - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['PATH_INFO'] = '/controller/method'; + service('superglobals')->setServer('REQUEST_URI', '/ci/v4/index.php/controller/method'); + service('superglobals')->setServer('SCRIPT_NAME', '/ci/v4/index.php'); + service('superglobals')->setServer('QUERY_STRING', ''); + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('PATH_INFO', '/controller/method'); $this->resetServices(); @@ -1124,11 +1124,11 @@ public function testForceGlobalSecureRequests(): void { $this->resetServices(); - $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; - $_SERVER['SCRIPT_NAME'] = '/ci/v4/index.php'; - $_SERVER['QUERY_STRING'] = ''; - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['PATH_INFO'] = '/controller/method'; + service('superglobals')->setServer('REQUEST_URI', '/ci/v4/controller/method'); + service('superglobals')->setServer('SCRIPT_NAME', '/ci/v4/index.php'); + service('superglobals')->setServer('QUERY_STRING', ''); + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('PATH_INFO', '/controller/method'); $config = new App(); $config->baseURL = 'http://example.com/ci/v4'; diff --git a/tests/system/Helpers/CookieHelperTest.php b/tests/system/Helpers/CookieHelperTest.php index c6bfba193054..abbc291ceb38 100644 --- a/tests/system/Helpers/CookieHelperTest.php +++ b/tests/system/Helpers/CookieHelperTest.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockResponse; use Config\App; @@ -44,6 +45,8 @@ protected function setUp(): void parent::setUp(); + Services::injectMock('superglobals', new Superglobals()); + $this->name = 'greetings'; $this->value = 'hello world'; $this->expire = 9999; @@ -152,14 +155,14 @@ public function testDeleteCookie(): void public function testGetCookie(): void { - $_COOKIE['TEST'] = '5'; + service('superglobals')->setCookie('TEST', '5'); $this->assertSame('5', get_cookie('TEST')); } public function testGetCookieDefaultPrefix(): void { - $_COOKIE['prefix_TEST'] = '5'; + service('superglobals')->setCookie('prefix_TEST', '5'); $config = new CookieConfig(); $config->prefix = 'prefix_'; @@ -170,7 +173,7 @@ public function testGetCookieDefaultPrefix(): void public function testGetCookiePrefix(): void { - $_COOKIE['abc_TEST'] = '5'; + service('superglobals')->setCookie('abc_TEST', '5'); $config = new CookieConfig(); $config->prefix = 'prefix_'; @@ -181,7 +184,7 @@ public function testGetCookiePrefix(): void public function testGetCookieNoPrefix(): void { - $_COOKIE['abc_TEST'] = '5'; + service('superglobals')->setCookie('abc_TEST', '5'); $config = new CookieConfig(); $config->prefix = 'prefix_'; diff --git a/tests/system/Helpers/FormHelperTest.php b/tests/system/Helpers/FormHelperTest.php index 91d5aa2e8c7b..58c1cb2a94cf 100644 --- a/tests/system/Helpers/FormHelperTest.php +++ b/tests/system/Helpers/FormHelperTest.php @@ -13,12 +13,15 @@ namespace CodeIgniter\Helpers; +use CodeIgniter\Config\Services as CodeIgniterServices; use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use Config\DocTypes; use Config\Filters; use Config\Services; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -27,6 +30,7 @@ /** * @internal */ +#[BackupGlobals(true)] #[Group('SeparateProcess')] final class FormHelperTest extends CIUnitTestCase { @@ -37,6 +41,10 @@ protected function setUp(): void parent::setUp(); + $_POST = $_GET = []; + + CodeIgniterServices::injectMock('superglobals', new Superglobals()); + helper('form'); } @@ -573,9 +581,9 @@ public function testFormDropdownInferred(): void \n EOH; - $_POST['cars'] = 'audi'; + service('superglobals')->setPost('cars', 'audi'); $this->assertSame($expected, form_dropdown('cars', $options)); - unset($_POST['cars']); + service('superglobals')->unsetPost('cars'); } public function testFormDropdownWithSelectedAttribute(): void @@ -975,7 +983,7 @@ public function testSetRadioFromSessionOldInput(): void #[RunInSeparateProcess] public function testSetRadioFromPost(): void { - $_POST['bar'] = 'baz'; + service('superglobals')->setPost('bar', 'baz'); $this->assertSame(' checked="checked"', set_radio('bar', 'baz')); $this->assertSame('', set_radio('bar', 'boop')); @@ -986,12 +994,12 @@ public function testSetRadioFromPost(): void #[RunInSeparateProcess] public function testSetRadioFromPostWithValueZero(): void { - $_POST['bar'] = '0'; + service('superglobals')->setPost('bar', '0'); $this->assertSame(' checked="checked"', set_radio('bar', '0')); $this->assertSame('', set_radio('bar', 'boop')); - $_POST = []; + service('superglobals')->setPostArray([]); $this->assertSame(' checked="checked"', set_radio('bar', '0', true)); } @@ -1033,7 +1041,6 @@ public function testSetRadioFromSessionOldInputPostArrayWithValueZero(): void public function testSetRadioDefault(): void { $_SESSION = []; - $_POST = []; $this->assertSame(' checked="checked"', set_radio('code', 'alpha', true)); $this->assertSame('', set_radio('code', 'beta', false)); diff --git a/tests/system/Helpers/URLHelper/CurrentUrlTest.php b/tests/system/Helpers/URLHelper/CurrentUrlTest.php index b802db349949..54ac56bb6a54 100644 --- a/tests/system/Helpers/URLHelper/CurrentUrlTest.php +++ b/tests/system/Helpers/URLHelper/CurrentUrlTest.php @@ -50,22 +50,23 @@ protected function setUp(): void $this->config->baseURL = 'http://example.com/'; $this->config->indexPage = 'index.php'; - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + Services::injectMock('superglobals', new Superglobals()); + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); } protected function tearDown(): void { parent::tearDown(); - $_SERVER = []; + service('superglobals')->setServerArray([]); } public function testCurrentURLReturnsBasicURL(): void { - $_SERVER['REQUEST_URI'] = '/public/'; - $_SERVER['SCRIPT_NAME'] = '/public/index.php'; + service('superglobals')->setServer('REQUEST_URI', '/public/'); + service('superglobals')->setServer('SCRIPT_NAME', '/public/index.php'); $this->config->baseURL = 'http://example.com/public/'; @@ -76,9 +77,9 @@ public function testCurrentURLReturnsBasicURL(): void public function testCurrentURLReturnsAllowedHostname(): void { - $_SERVER['HTTP_HOST'] = 'www.example.jp'; - $_SERVER['REQUEST_URI'] = '/public/'; - $_SERVER['SCRIPT_NAME'] = '/public/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'www.example.jp'); + service('superglobals')->setServer('REQUEST_URI', '/public/'); + service('superglobals')->setServer('SCRIPT_NAME', '/public/index.php'); $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; @@ -107,9 +108,9 @@ private function createRequest(?App $config = null, $body = null, ?string $path public function testCurrentURLReturnsBaseURLIfNotAllowedHostname(): void { - $_SERVER['HTTP_HOST'] = 'invalid.example.org'; - $_SERVER['REQUEST_URI'] = '/public/'; - $_SERVER['SCRIPT_NAME'] = '/public/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'invalid.example.org'); + service('superglobals')->setServer('REQUEST_URI', '/public/'); + service('superglobals')->setServer('SCRIPT_NAME', '/public/index.php'); $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; @@ -133,9 +134,9 @@ public function testCurrentURLReturnsObject(): void public function testCurrentURLEquivalence(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/public/'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/public/'); + service('superglobals')->setServer('SCRIPT_NAME', '/index.php'); $this->config->indexPage = ''; @@ -146,9 +147,9 @@ public function testCurrentURLEquivalence(): void public function testCurrentURLInSubfolder(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/foo/public/bar?baz=quip'; - $_SERVER['SCRIPT_NAME'] = '/foo/public/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/foo/public/bar?baz=quip'); + service('superglobals')->setServer('SCRIPT_NAME', '/foo/public/index.php'); $this->config->baseURL = 'http://example.com/foo/public/'; @@ -165,10 +166,10 @@ public function testCurrentURLInSubfolder(): void public function testCurrentURLWithPortInSubfolder(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['SERVER_PORT'] = '8080'; - $_SERVER['REQUEST_URI'] = '/foo/public/bar?baz=quip'; - $_SERVER['SCRIPT_NAME'] = '/foo/public/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('SERVER_PORT', '8080'); + service('superglobals')->setServer('REQUEST_URI', '/foo/public/bar?baz=quip'); + service('superglobals')->setServer('SCRIPT_NAME', '/foo/public/index.php'); $this->config->baseURL = 'http://example.com:8080/foo/public/'; @@ -187,8 +188,8 @@ public function testCurrentURLWithPortInSubfolder(): void public function testUriString(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/assets/image.jpg'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/assets/image.jpg'); $this->config->indexPage = ''; @@ -199,8 +200,8 @@ public function testUriString(): void public function testUriStringNoTrailingSlash(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/assets/image.jpg'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/assets/image.jpg'); $this->config->baseURL = 'http://example.com/'; $this->config->indexPage = ''; @@ -219,8 +220,8 @@ public function testUriStringEmpty(): void public function testUriStringSubfolderAbsolute(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/subfolder/assets/image.jpg'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/subfolder/assets/image.jpg'); $this->config->baseURL = 'http://example.com/subfolder/'; @@ -231,9 +232,9 @@ public function testUriStringSubfolderAbsolute(): void public function testUriStringSubfolderRelative(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/subfolder/assets/image.jpg'; - $_SERVER['SCRIPT_NAME'] = '/subfolder/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/subfolder/assets/image.jpg'); + service('superglobals')->setServer('SCRIPT_NAME', '/subfolder/index.php'); $this->config->baseURL = 'http://example.com/subfolder/'; @@ -245,8 +246,8 @@ public function testUriStringSubfolderRelative(): void #[DataProvider('provideUrlIs')] public function testUrlIs(string $currentPath, string $testPath, bool $expected): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/' . $currentPath; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/' . $currentPath); $this->createRequest($this->config); @@ -256,8 +257,8 @@ public function testUrlIs(string $currentPath, string $testPath, bool $expected) #[DataProvider('provideUrlIs')] public function testUrlIsNoIndex(string $currentPath, string $testPath, bool $expected): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/' . $currentPath; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/' . $currentPath); $this->config->indexPage = ''; @@ -269,9 +270,9 @@ public function testUrlIsNoIndex(string $currentPath, string $testPath, bool $ex #[DataProvider('provideUrlIs')] public function testUrlIsWithSubfolder(string $currentPath, string $testPath, bool $expected): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/' . $currentPath; - $_SERVER['SCRIPT_NAME'] = '/subfolder/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/' . $currentPath); + service('superglobals')->setServer('SCRIPT_NAME', '/subfolder/index.php'); $this->config->baseURL = 'http://example.com/subfolder/'; diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index 3224e426914a..cc7830084a96 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -44,6 +44,7 @@ protected function setUp(): void parent::setUp(); Services::reset(true); + Services::injectMock('superglobals', new Superglobals()); service('routes')->loadRoutes(); // Set a common base configuration (overriden by individual tests) @@ -56,7 +57,7 @@ protected function tearDown(): void { parent::tearDown(); - $_SERVER = []; + service('superglobals')->setServerArray([]); } #[Group('SeparateProcess')] @@ -68,7 +69,7 @@ public function testPreviousURLUsesSessionFirst(): void $uri1 = 'http://example.com/one?two'; $uri2 = 'http://example.com/two?foo'; - $_SERVER['HTTP_REFERER'] = $uri1; + service('superglobals')->setServer('HTTP_REFERER', $uri1); session()->set('_ci_previous_url', $uri2); $this->config->baseURL = 'http://example.com/public'; @@ -98,7 +99,7 @@ public function testPreviousURLUsesRefererIfNeeded(): void { $uri1 = 'http://example.com/one?two'; - $_SERVER['HTTP_REFERER'] = $uri1; + service('superglobals')->setServer('HTTP_REFERER', $uri1); $this->config->baseURL = 'http://example.com/public'; @@ -844,7 +845,7 @@ public function testMbUrlTitleExtraDashes(): void #[DataProvider('provideUrlTo')] public function testUrlTo(string $expected, string $input, ...$args): void { - $_SERVER['HTTP_HOST'] = 'example.com'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); $routes = service('routes'); // @TODO Do not put any placeholder after (:any). diff --git a/tests/system/Helpers/URLHelper/SiteUrlTest.php b/tests/system/Helpers/URLHelper/SiteUrlTest.php index b8c1dff4678b..791515a09fab 100644 --- a/tests/system/Helpers/URLHelper/SiteUrlTest.php +++ b/tests/system/Helpers/URLHelper/SiteUrlTest.php @@ -43,6 +43,7 @@ protected function setUp(): void parent::setUp(); Services::reset(true); + Services::injectMock('superglobals', new Superglobals()); $this->config = new App(); } @@ -353,15 +354,15 @@ public function testBaseURLDiscovery(): void { $this->config->baseURL = 'http://example.com/'; - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/test'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/test'); $this->createRequest($this->config); $this->assertSame('http://example.com/', base_url()); - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/test/page'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/test/page'); $this->createRequest($this->config); @@ -371,8 +372,8 @@ public function testBaseURLDiscovery(): void public function testBaseURLService(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/ci/v4/x/y'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + service('superglobals')->setServer('REQUEST_URI', '/ci/v4/x/y'); $this->config->baseURL = 'http://example.com/ci/v4/'; @@ -390,7 +391,9 @@ public function testBaseURLService(): void public function testBaseURLWithCLIRequest(): void { - unset($_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI']); + $superglobals = service('superglobals'); + $superglobals->unsetServer('HTTP_HOST'); + $superglobals->unsetServer('REQUEST_URI'); $this->config->baseURL = 'http://example.com/'; @@ -408,9 +411,9 @@ public function testBaseURLWithCLIRequest(): void public function testSiteURLWithAllowedHostname(): void { - $_SERVER['HTTP_HOST'] = 'www.example.jp'; - $_SERVER['REQUEST_URI'] = '/public'; - $_SERVER['SCRIPT_NAME'] = '/public/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'www.example.jp'); + service('superglobals')->setServer('REQUEST_URI', '/public'); + service('superglobals')->setServer('SCRIPT_NAME', '/public/index.php'); $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; @@ -425,9 +428,9 @@ public function testSiteURLWithAllowedHostname(): void public function testSiteURLWithAltConfig(): void { - $_SERVER['HTTP_HOST'] = 'www.example.jp'; - $_SERVER['REQUEST_URI'] = '/public'; - $_SERVER['SCRIPT_NAME'] = '/public/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'www.example.jp'); + service('superglobals')->setServer('REQUEST_URI', '/public'); + service('superglobals')->setServer('SCRIPT_NAME', '/public/index.php'); $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; @@ -445,9 +448,9 @@ public function testSiteURLWithAltConfig(): void public function testBaseURLWithAllowedHostname(): void { - $_SERVER['HTTP_HOST'] = 'www.example.jp'; - $_SERVER['REQUEST_URI'] = '/public'; - $_SERVER['SCRIPT_NAME'] = '/public/index.php'; + service('superglobals')->setServer('HTTP_HOST', 'www.example.jp'); + service('superglobals')->setServer('REQUEST_URI', '/public'); + service('superglobals')->setServer('SCRIPT_NAME', '/public/index.php'); $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; diff --git a/tests/system/Honeypot/HoneypotTest.php b/tests/system/Honeypot/HoneypotTest.php index b92c2d1158b7..8c85da575a95 100644 --- a/tests/system/Honeypot/HoneypotTest.php +++ b/tests/system/Honeypot/HoneypotTest.php @@ -14,12 +14,14 @@ namespace CodeIgniter\Honeypot; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; use CodeIgniter\Filters\Filters; use CodeIgniter\Honeypot\Exceptions\HoneypotException; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\Response; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use Config\Honeypot as HoneypotConfig; @@ -47,12 +49,14 @@ protected function setUp(): void { parent::setUp(); + Services::injectMock('superglobals', new Superglobals()); + $this->config = new HoneypotConfig(); $this->honeypot = new Honeypot($this->config); - unset($_POST[$this->config->name]); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST[$this->config->name] = 'hey'; + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'POST'); + $superglobals->setPost($this->config->name, 'hey'); $this->request = service('request', null, false); $this->response = service('response'); @@ -131,7 +135,7 @@ public function testNotAttachHoneypotWithCSP(): void public function testHasntContent(): void { - unset($_POST[$this->config->name]); + service('superglobals')->unsetPost($this->config->name); $this->request = service('request'); $this->assertFalse($this->honeypot->hasContent($this->request)); diff --git a/tests/system/Log/LoggerTest.php b/tests/system/Log/LoggerTest.php index 623b665cfda3..80d42d6c83b3 100644 --- a/tests/system/Log/LoggerTest.php +++ b/tests/system/Log/LoggerTest.php @@ -131,8 +131,8 @@ public function testLogInterpolatesPost(): void Time::setTestNow('2023-11-25 12:00:00'); - $_POST = ['foo' => 'bar']; - $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message $_POST: ' . print_r($_POST, true); + service('superglobals')->setPost('foo', 'bar'); + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message $_POST: ' . print_r(service('superglobals')->getPostArray(), true); $logger->log('debug', 'Test message {post_vars}'); @@ -150,8 +150,8 @@ public function testLogInterpolatesGet(): void Time::setTestNow('2023-11-25 12:00:00'); - $_GET = ['bar' => 'baz']; - $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message $_GET: ' . print_r($_GET, true); + service('superglobals')->setGet('bar', 'baz'); + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message $_GET: ' . print_r(service('superglobals')->getGetArray(), true); $logger->log('debug', 'Test message {get_vars}'); diff --git a/tests/system/Models/PaginateModelTest.php b/tests/system/Models/PaginateModelTest.php index 43a915bf101e..08c85dfb0590 100644 --- a/tests/system/Models/PaginateModelTest.php +++ b/tests/system/Models/PaginateModelTest.php @@ -86,7 +86,7 @@ public function testPaginatePageOutOfRange(): void public function testMultiplePager(): void { - $_GET = []; + service('superglobals')->setGetArray([]); $validModel = $this->createModel(ValidModel::class); $userModel = $this->createModel(UserModel::class); diff --git a/tests/system/Pager/PagerTest.php b/tests/system/Pager/PagerTest.php index 3e63f555dfb6..b1ab5d7dc4cd 100644 --- a/tests/system/Pager/PagerTest.php +++ b/tests/system/Pager/PagerTest.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Pager\Exceptions\PagerException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use Config\Pager as PagerConfig; @@ -40,14 +41,16 @@ protected function setUp(): void { parent::setUp(); + Services::injectMock('superglobals', new Superglobals([], [], [], [], [], [])); + $this->createPager('/'); } private function createPager(string $requestUri): void { - $_SERVER['REQUEST_URI'] = $requestUri; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - $_GET = []; + service('superglobals') + ->setServer('REQUEST_URI', $requestUri) + ->setServer('SCRIPT_NAME', '/index.php'); $config = new App(); $config->baseURL = 'http://example.com/'; @@ -78,7 +81,7 @@ public function testSetPathRemembersPath(): void public function testGetDetailsRecognizesPageQueryVar(): void { - $_GET['page'] = 2; + service('superglobals')->setGet('page', '2'); // Need this to create the group. $this->pager->setPath('foo/bar'); @@ -90,7 +93,7 @@ public function testGetDetailsRecognizesPageQueryVar(): void public function testGetDetailsRecognizesGroupedPageQueryVar(): void { - $_GET['page_foo'] = 2; + service('superglobals')->setGet('page_foo', '2'); // Need this to create the group. $this->pager->setPath('foo/bar', 'foo'); @@ -155,8 +158,9 @@ public function testStoreAndHasMoreCanBeFalse(): void public function testStoreWithQueries(): void { - $_GET['page'] = 3; - $_GET['foo'] = 'bar'; + service('superglobals') + ->setGet('page', '3') + ->setGet('foo', 'bar'); $this->pager->store('default', 3, 25, 100); @@ -175,8 +179,9 @@ public function testStoreWithQueries(): void public function testStoreWithSegments(): void { - $_GET['page'] = 3; - $_GET['foo'] = 'bar'; + service('superglobals') + ->setGet('page', '3') + ->setGet('foo', 'bar'); $this->pager->store('default', 3, 25, 100, 1); @@ -233,14 +238,14 @@ public function testGetCurrentPageRemembersStoredPage(): void public function testGetCurrentPageDetectsURI(): void { - $_GET['page'] = 2; + service('superglobals')->setGet('page', '2'); $this->assertSame(2, $this->pager->getCurrentPage()); } public function testGetCurrentPageDetectsGroupedURI(): void { - $_GET['page_foo'] = 2; + service('superglobals')->setGet('page_foo', '2'); $this->assertSame(2, $this->pager->getCurrentPage('foo')); } @@ -276,7 +281,7 @@ public function testGetTotalPagesCalcsCorrectValue(): void public function testGetNextURIUsesCurrentURI(): void { - $_GET['page_foo'] = 2; + service('superglobals')->setGet('page_foo', '2'); $this->pager->store('foo', 2, 12, 70); @@ -305,7 +310,7 @@ public function testGetNextURICorrectOnFirstPage(): void public function testGetPreviousURIUsesCurrentURI(): void { - $_GET['page_foo'] = 2; + service('superglobals')->setGet('page_foo', '2'); $this->pager->store('foo', 2, 12, 70); @@ -324,48 +329,51 @@ public function testGetNextURIReturnsNullOnFirstPage(): void public function testGetNextURIWithQueryStringUsesCurrentURI(): void { - $_GET = [ - 'page_foo' => 3, - 'status' => 1, - ]; + service('superglobals') + ->setGet('page_foo', '3') + ->setGet('status', '1'); + $getArray = service('superglobals')->getGetArray(); $expected = current_url(true); - $expected = (string) $expected->setQueryArray($_GET); + $expected = (string) $expected->setQueryArray($getArray); - $this->pager->store('foo', $_GET['page_foo'] - 1, 12, 70); + $this->pager->store('foo', (int) $getArray['page_foo'] - 1, 12, 70); $this->assertSame($expected, $this->pager->getNextPageURI('foo')); } public function testGetPreviousURIWithQueryStringUsesCurrentURI(): void { - $_GET = [ - 'page_foo' => 1, - 'status' => 1, - ]; + service('superglobals') + ->setGet('page_foo', '1') + ->setGet('status', '1'); + + $getArray = service('superglobals')->getGetArray(); $expected = current_url(true); - $expected = (string) $expected->setQueryArray($_GET); + $expected = (string) $expected->setQueryArray($getArray); - $this->pager->store('foo', $_GET['page_foo'] + 1, 12, 70); + $this->pager->store('foo', (int) $getArray['page_foo'] + 1, 12, 70); $this->assertSame($expected, $this->pager->getPreviousPageURI('foo')); } public function testGetOnlyQueries(): void { - $_GET = [ - 'page' => 2, + $getArray = [ + 'page' => '2', 'search' => 'foo', 'order' => 'asc', 'hello' => 'xxx', 'category' => 'baz', ]; + service('superglobals')->setGetArray($getArray); + $onlyQueries = [ 'search', 'order', ]; - $this->pager->store('default', $_GET['page'], 10, 100); + $this->pager->store('default', (int) $getArray['page'], 10, 100); $uri = current_url(true); @@ -473,10 +481,10 @@ public function testHeadLinks(): void public function testBasedURI(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/ci/v4/x/y'; - $_SERVER['SCRIPT_NAME'] = '/ci/v4/index.php'; - $_GET = []; + service('superglobals') + ->setServer('HTTP_HOST', 'example.com') + ->setServer('REQUEST_URI', '/ci/v4/x/y') + ->setServer('SCRIPT_NAME', '/ci/v4/index.php'); $config = new App(); $config->baseURL = 'http://example.com/ci/v4/'; @@ -495,7 +503,7 @@ public function testBasedURI(): void $this->config = new PagerConfig(); $this->pager = new Pager($this->config, service('renderer')); - $_GET['page_foo'] = 2; + service('superglobals')->setGet('page_foo', '2'); $this->pager->store('foo', 2, 12, 70); diff --git a/tests/system/RESTful/ResourceControllerTest.php b/tests/system/RESTful/ResourceControllerTest.php index fcbe08070a81..c51e4ae39174 100644 --- a/tests/system/RESTful/ResourceControllerTest.php +++ b/tests/system/RESTful/ResourceControllerTest.php @@ -23,6 +23,7 @@ use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Model; use CodeIgniter\Router\RouteCollection; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; use CodeIgniter\Test\Mock\MockResourceController; @@ -62,11 +63,13 @@ protected function setUp(): void $this->resetServices(true); $this->resetFactories(); + + Services::injectMock('superglobals', new Superglobals()); } private function createCodeigniter(): void { - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); // Inject mock router. $this->routes = service('routes'); @@ -91,14 +94,14 @@ protected function tearDown(): void public function testResourceGet(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', - ]; - $_SERVER['argc'] = 2; + ]); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/work'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -112,15 +115,15 @@ public function testResourceGet(): void public function testResourceGetNew(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'new', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/new'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work/new'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -134,16 +137,16 @@ public function testResourceGetNew(): void public function testResourceGetEdit(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', '1', 'edit', - ]; - $_SERVER['argc'] = 4; + ]); + service('superglobals')->setServer('argc', 4); - $_SERVER['REQUEST_URI'] = '/work/1/edit'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work/1/edit'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -157,15 +160,15 @@ public function testResourceGetEdit(): void public function testResourceGetOne(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', '1', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work/1'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -179,14 +182,14 @@ public function testResourceGetOne(): void public function testResourcePost(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', - ]; - $_SERVER['argc'] = 2; + ]); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/work'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_URI', '/work'); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $this->createCodeigniter(); @@ -200,15 +203,15 @@ public function testResourcePost(): void public function testResourcePatch(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', '123', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/123'; - $_SERVER['REQUEST_METHOD'] = 'PATCH'; + service('superglobals')->setServer('REQUEST_URI', '/work/123'); + service('superglobals')->setServer('REQUEST_METHOD', 'PATCH'); $this->createCodeigniter(); @@ -222,15 +225,15 @@ public function testResourcePatch(): void public function testResourcePut(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', '123', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/123'; - $_SERVER['REQUEST_METHOD'] = 'PUT'; + service('superglobals')->setServer('REQUEST_URI', '/work/123'); + service('superglobals')->setServer('REQUEST_METHOD', 'PUT'); $this->createCodeigniter(); @@ -244,15 +247,15 @@ public function testResourcePut(): void public function testResourceDelete(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', '123', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/123'; - $_SERVER['REQUEST_METHOD'] = 'DELETE'; + service('superglobals')->setServer('REQUEST_URI', '/work/123'); + service('superglobals')->setServer('REQUEST_METHOD', 'DELETE'); $this->createCodeigniter(); diff --git a/tests/system/RESTful/ResourcePresenterTest.php b/tests/system/RESTful/ResourcePresenterTest.php index db10f07f6588..7e002d8e48b7 100644 --- a/tests/system/RESTful/ResourcePresenterTest.php +++ b/tests/system/RESTful/ResourcePresenterTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Config\Services; use CodeIgniter\Model; use CodeIgniter\Router\RouteCollection; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; use CodeIgniter\Test\Mock\MockResourcePresenter; @@ -56,11 +57,13 @@ protected function setUp(): void $this->resetServices(true); $this->resetFactories(); + + Services::injectMock('superglobals', new Superglobals()); } private function createCodeigniter(): void { - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); // Inject mock router. $this->routes = service('routes'); @@ -82,14 +85,14 @@ protected function tearDown(): void public function testResourceGet(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', - ]; - $_SERVER['argc'] = 2; + ]); + service('superglobals')->setServer('argc', 2); - $_SERVER['REQUEST_URI'] = '/work'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -102,16 +105,16 @@ public function testResourceGet(): void public function testResourceShow(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'show', '1', - ]; - $_SERVER['argc'] = 4; + ]); + service('superglobals')->setServer('argc', 4); - $_SERVER['REQUEST_URI'] = '/work/show/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work/show/1'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -124,15 +127,15 @@ public function testResourceShow(): void public function testResourceNew(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'new', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/new'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work/new'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -145,15 +148,15 @@ public function testResourceNew(): void public function testResourceCreate(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'create', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/create'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_URI', '/work/create'); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $this->createCodeigniter(); @@ -166,16 +169,16 @@ public function testResourceCreate(): void public function testResourceRemove(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'remove', '123', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/remove/123'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work/remove/123'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -188,16 +191,16 @@ public function testResourceRemove(): void public function testResourceDelete(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'delete', '123', - ]; - $_SERVER['argc'] = 3; + ]); + service('superglobals')->setServer('argc', 3); - $_SERVER['REQUEST_URI'] = '/work/delete/123'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_URI', '/work/delete/123'); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $this->createCodeigniter(); @@ -210,17 +213,17 @@ public function testResourceDelete(): void public function testResourceEdit(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'edit', '1', 'edit', - ]; - $_SERVER['argc'] = 4; + ]); + service('superglobals')->setServer('argc', 4); - $_SERVER['REQUEST_URI'] = '/work/edit/1'; - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_URI', '/work/edit/1'); + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $this->createCodeigniter(); @@ -233,16 +236,16 @@ public function testResourceEdit(): void public function testResourceUpdate(): void { - $_SERVER['argv'] = [ + service('superglobals')->setServer('argv', [ 'index.php', 'work', 'update', '123', - ]; - $_SERVER['argc'] = 4; + ]); + service('superglobals')->setServer('argc', 4); - $_SERVER['REQUEST_URI'] = '/work/update/123'; - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_URI', '/work/update/123'); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $this->createCodeigniter(); diff --git a/tests/system/Router/RouteCollectionTest.php b/tests/system/Router/RouteCollectionTest.php index 34fe4f088d1e..6346697d206a 100644 --- a/tests/system/Router/RouteCollectionTest.php +++ b/tests/system/Router/RouteCollectionTest.php @@ -15,9 +15,11 @@ use App\Controllers\Home; use App\Controllers\Product; +use CodeIgniter\Config\Services; use CodeIgniter\Controller; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\Method; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\Feature; use Config\Modules; @@ -38,6 +40,8 @@ protected function setUp(): void $this->resetServices(true); $this->resetFactories(); + + Services::injectMock('superglobals', new Superglobals()); } protected function getCollector(array $config = [], array $files = [], $moduleConfig = null): RouteCollection @@ -551,7 +555,7 @@ public static function provideNestedGroupingWorksWithRootPrefix(): iterable public function testHostnameOption(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); $routes = $this->getCollector(); @@ -1161,7 +1165,7 @@ public function testAddRedirectGetMethod(): void */ public function testWithSubdomain(): void { - $_SERVER['HTTP_HOST'] = 'adm.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'adm.example.com'); $routes = $this->getCollector(); @@ -1176,7 +1180,7 @@ public function testWithSubdomain(): void public function testWithSubdomainMissing(): void { - $_SERVER['HTTP_HOST'] = 'www.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'www.example.com'); $routes = $this->getCollector(); @@ -1191,7 +1195,7 @@ public function testWithSubdomainMissing(): void public function testWithDifferentSubdomain(): void { - $_SERVER['HTTP_HOST'] = 'adm.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'adm.example.com'); $routes = $this->getCollector(); @@ -1208,7 +1212,7 @@ public function testWithWWWSubdomain(): void { $routes = $this->getCollector(); - $_SERVER['HTTP_HOST'] = 'www.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'www.example.com'); $routes->add('/objects/(:alphanum)', 'Admin::objectsList/$1', ['subdomain' => 'sales']); $routes->add('/objects/(:alphanum)', 'App::objectsList/$1'); @@ -1224,7 +1228,7 @@ public function testWithDotCoSubdomain(): void { $routes = $this->getCollector(); - $_SERVER['HTTP_HOST'] = 'example.co.uk'; + service('superglobals')->setServer('HTTP_HOST', 'example.co.uk'); $routes->add('/objects/(:alphanum)', 'Admin::objectsList/$1', ['subdomain' => 'sales']); $routes->add('/objects/(:alphanum)', 'App::objectsList/$1'); @@ -1238,7 +1242,7 @@ public function testWithDotCoSubdomain(): void public function testWithDifferentSubdomainMissing(): void { - $_SERVER['HTTP_HOST'] = 'adm.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'adm.example.com'); $routes = $this->getCollector(); @@ -1257,7 +1261,7 @@ public function testWithDifferentSubdomainMissing(): void */ public function testWithNoSubdomainAndDot(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); $routes = $this->getCollector(); @@ -1271,7 +1275,7 @@ public function testWithNoSubdomainAndDot(): void */ public function testWithSubdomainOrdered(): void { - $_SERVER['HTTP_HOST'] = 'adm.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'adm.example.com'); $routes = $this->getCollector(); @@ -1531,7 +1535,7 @@ public function testOffsetParameters(): void */ public function testRouteToWithSubdomainMatch(): void { - $_SERVER['HTTP_HOST'] = 'doc.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1543,7 +1547,7 @@ public function testRouteToWithSubdomainMatch(): void public function testRouteToWithSubdomainMismatch(): void { - $_SERVER['HTTP_HOST'] = 'dev.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'dev.example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1555,7 +1559,7 @@ public function testRouteToWithSubdomainMismatch(): void public function testRouteToWithSubdomainNot(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1567,7 +1571,7 @@ public function testRouteToWithSubdomainNot(): void public function testRouteToWithGenericSubdomainMatch(): void { - $_SERVER['HTTP_HOST'] = 'doc.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1579,7 +1583,7 @@ public function testRouteToWithGenericSubdomainMatch(): void public function testRouteToWithGenericSubdomainMismatch(): void { - $_SERVER['HTTP_HOST'] = 'dev.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'dev.example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1591,7 +1595,7 @@ public function testRouteToWithGenericSubdomainMismatch(): void public function testRouteToWithGenericSubdomainNot(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1603,7 +1607,7 @@ public function testRouteToWithGenericSubdomainNot(): void public function testRouteToWithoutSubdomainMatch(): void { - $_SERVER['HTTP_HOST'] = 'doc.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1615,7 +1619,7 @@ public function testRouteToWithoutSubdomainMatch(): void public function testRouteToWithoutSubdomainMismatch(): void { - $_SERVER['HTTP_HOST'] = 'dev.example.com'; + service('superglobals')->setServer('HTTP_HOST', 'dev.example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1627,7 +1631,7 @@ public function testRouteToWithoutSubdomainMismatch(): void public function testRouteToWithoutSubdomainNot(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; + service('superglobals')->setServer('HTTP_HOST', 'example.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1644,7 +1648,7 @@ public function testRouteToWithoutSubdomainNot(): void */ public function testRouteOverwritingDifferentSubdomains(): void { - $_SERVER['HTTP_HOST'] = 'doc.domain.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.domain.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1665,7 +1669,7 @@ public function testRouteOverwritingDifferentSubdomains(): void public function testRouteOverwritingTwoRules(): void { - $_SERVER['HTTP_HOST'] = 'doc.domain.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.domain.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1686,7 +1690,7 @@ public function testRouteOverwritingTwoRules(): void public function testRouteOverwritingTwoRulesLastApplies(): void { - $_SERVER['HTTP_HOST'] = 'doc.domain.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.domain.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1706,7 +1710,7 @@ public function testRouteOverwritingTwoRulesLastApplies(): void public function testRouteOverwritingMatchingSubdomain(): void { - $_SERVER['HTTP_HOST'] = 'doc.domain.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.domain.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); @@ -1726,7 +1730,7 @@ public function testRouteOverwritingMatchingSubdomain(): void public function testRouteOverwritingMatchingHost(): void { - $_SERVER['HTTP_HOST'] = 'doc.domain.com'; + service('superglobals')->setServer('HTTP_HOST', 'doc.domain.com'); service('request')->setMethod(Method::GET); $routes = $this->getCollector(); diff --git a/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php index 3d528e92f564..2b257c621c6d 100644 --- a/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php @@ -14,10 +14,12 @@ namespace CodeIgniter\Security; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; use CodeIgniter\Cookie\Cookie; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockAppConfig; use CodeIgniter\Test\Mock\MockSecurity; @@ -46,7 +48,7 @@ protected function setUp(): void { parent::setUp(); - $_COOKIE = []; + Services::injectMock('superglobals', new Superglobals(null, null, null, [])); $this->config = new SecurityConfig(); $this->config->csrfProtection = Security::CSRF_PROTECTION_COOKIE; @@ -54,8 +56,8 @@ protected function setUp(): void Factories::injectMock('config', 'Security', $this->config); // Set Cookie value - $security = new MockSecurity($this->config); - $_COOKIE[$security->getCookieName()] = $this->hash; + $security = new MockSecurity($this->config); + service('superglobals')->setCookie($security->getCookieName(), $this->hash); $this->resetServices(); } @@ -72,9 +74,9 @@ public function testTokenIsReadFromCookie(): void public function testCSRFVerifySetNewCookie(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; - $_POST['csrf_test_name'] = $this->randomizedToken; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setPost('foo', 'bar'); + service('superglobals')->setPost('csrf_test_name', $this->randomizedToken); $config = new MockAppConfig(); $request = new IncomingRequest($config, new SiteURI($config), null, new UserAgent()); @@ -83,7 +85,7 @@ public function testCSRFVerifySetNewCookie(): void $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, $_POST); + $this->assertCount(1, service('superglobals')->getPostArray()); /** @var Cookie $cookie */ $cookie = $this->getPrivateProperty($security, 'cookie'); diff --git a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php index da9449c38ac2..50ee40e061fd 100644 --- a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php @@ -22,6 +22,7 @@ use CodeIgniter\Session\Handlers\ArrayHandler; use CodeIgniter\Session\Handlers\FileHandler; use CodeIgniter\Session\Session; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockAppConfig; use CodeIgniter\Test\Mock\MockSecurity; @@ -65,6 +66,8 @@ protected function setUp(): void $_SESSION = []; Factories::reset(); + Services::injectMock('superglobals', new Superglobals()); + $this->config = new SecurityConfig(); $this->config->csrfProtection = Security::CSRF_PROTECTION_SESSION; $this->config->tokenRandomize = true; @@ -138,8 +141,7 @@ public function testCSRFVerifyPostNoToken(): void $this->expectException(SecurityException::class); $this->expectExceptionMessage('The action you requested is not allowed.'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - unset($_POST['csrf_test_name']); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $request = $this->createIncomingRequest(); $security = $this->createSecurity(); @@ -159,8 +161,8 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void $this->expectException(SecurityException::class); $this->expectExceptionMessage('The action you requested is not allowed.'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005b'); $request = $this->createIncomingRequest(); $security = $this->createSecurity(); @@ -176,8 +178,8 @@ public function testCSRFVerifyPostInvalidToken(): void $this->expectException(SecurityException::class); $this->expectExceptionMessage('The action you requested is not allowed.'); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '!'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setPost('csrf_test_name', '!'); $request = $this->createIncomingRequest(); $security = $this->createSecurity(); @@ -187,21 +189,21 @@ public function testCSRFVerifyPostInvalidToken(): void public function testCSRFVerifyPostReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; - $_POST['csrf_test_name'] = $this->randomizedToken; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setPost('foo', 'bar'); + service('superglobals')->setPost('csrf_test_name', $this->randomizedToken); $request = $this->createIncomingRequest(); $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, $_POST); + $this->assertCount(1, service('superglobals')->getPostArray()); } public function testCSRFVerifyPOSTHeaderThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); @@ -215,8 +217,8 @@ public function testCSRFVerifyPOSTHeaderThrowsExceptionOnNoMatch(): void public function testCSRFVerifyPOSTHeaderReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setPost('foo', 'bar'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', $this->randomizedToken); @@ -224,12 +226,12 @@ public function testCSRFVerifyPOSTHeaderReturnsSelfOnMatch(): void $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, $_POST); + $this->assertCount(1, service('superglobals')->getPostArray()); } public function testCSRFVerifyPUTHeaderThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; + service('superglobals')->setServer('REQUEST_METHOD', 'PUT'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); @@ -243,7 +245,7 @@ public function testCSRFVerifyPUTHeaderThrowsExceptionOnNoMatch(): void public function testCSRFVerifyPUTHeaderReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; + service('superglobals')->setServer('REQUEST_METHOD', 'PUT'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', $this->randomizedToken); @@ -255,7 +257,7 @@ public function testCSRFVerifyPUTHeaderReturnsSelfOnMatch(): void public function testCSRFVerifyPUTBodyReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; + service('superglobals')->setServer('REQUEST_METHOD', 'PUT'); $request = $this->createIncomingRequest(); $request->setBody("csrf_test_name={$this->randomizedToken}&foo=bar"); @@ -270,7 +272,7 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void $this->expectException(SecurityException::class); $this->expectExceptionMessage('The action you requested is not allowed.'); - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $request = $this->createIncomingRequest(); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005b"}'); @@ -281,7 +283,7 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void public function testCSRFVerifyJsonReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $request = $this->createIncomingRequest(); $request->setBody('{"csrf_test_name":"' . $this->randomizedToken . '","foo":"bar"}'); @@ -294,8 +296,8 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void public function testRegenerateWithFalseSecurityRegenerateProperty(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = $this->randomizedToken; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setPost('csrf_test_name', $this->randomizedToken); /** * @var SecurityConfig @@ -317,8 +319,8 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void public function testRegenerateWithTrueSecurityRegenerateProperty(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = $this->randomizedToken; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setPost('csrf_test_name', $this->randomizedToken); /** * @var SecurityConfig diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php index 71177cb85ad3..60eb30aa9931 100644 --- a/tests/system/Security/SecurityCSRFSessionTest.php +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -22,6 +22,7 @@ use CodeIgniter\Session\Handlers\ArrayHandler; use CodeIgniter\Session\Handlers\FileHandler; use CodeIgniter\Session\Session; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockAppConfig; use CodeIgniter\Test\Mock\MockSession; @@ -59,6 +60,8 @@ protected function setUp(): void $_SESSION = []; Factories::reset(); + Services::injectMock('superglobals', new Superglobals()); + $this->config = new SecurityConfig(); $this->config->csrfProtection = Security::CSRF_PROTECTION_SESSION; Factories::injectMock('config', 'Security', $this->config); @@ -127,8 +130,9 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void { $this->expectException(SecurityException::class); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005b'); $request = $this->createIncomingRequest(); $security = $this->createSecurity(); @@ -145,21 +149,22 @@ private function createIncomingRequest(?App $config = null): IncomingRequest public function testCSRFVerifyPostReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('foo', 'bar') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a'); $request = $this->createIncomingRequest(); $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, $_POST); + $this->assertCount(1, service('superglobals')->getPostArray()); } public function testCSRFVerifyPOSTHeaderThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); @@ -171,8 +176,9 @@ public function testCSRFVerifyPOSTHeaderThrowsExceptionOnNoMatch(): void public function testCSRFVerifyPOSTHeaderReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('foo', 'bar'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); @@ -180,12 +186,12 @@ public function testCSRFVerifyPOSTHeaderReturnsSelfOnMatch(): void $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, $_POST); + $this->assertCount(1, service('superglobals')->getPostArray()); } public function testCSRFVerifyPUTHeaderThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; + service('superglobals')->setServer('REQUEST_METHOD', 'PUT'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); @@ -197,7 +203,7 @@ public function testCSRFVerifyPUTHeaderThrowsExceptionOnNoMatch(): void public function testCSRFVerifyPUTHeaderReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; + service('superglobals')->setServer('REQUEST_METHOD', 'PUT'); $request = $this->createIncomingRequest(); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); @@ -209,7 +215,7 @@ public function testCSRFVerifyPUTHeaderReturnsSelfOnMatch(): void public function testCSRFVerifyPUTBodyReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; + service('superglobals')->setServer('REQUEST_METHOD', 'PUT'); $request = $this->createIncomingRequest(); $request->setBody('csrf_test_name=8b9218a55906f9dcc1dc263dce7f005a&foo=bar'); @@ -223,7 +229,7 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void { $this->expectException(SecurityException::class); - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $request = $this->createIncomingRequest(); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005b"}'); @@ -234,7 +240,7 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void public function testCSRFVerifyJsonReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); $request = $this->createIncomingRequest(); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a","foo":"bar"}'); @@ -247,8 +253,9 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void public function testRegenerateWithFalseSecurityRegenerateProperty(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a'); /** * @var SecurityConfig @@ -269,8 +276,9 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void public function testRegenerateWithTrueSecurityRegenerateProperty(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a'); /** * @var SecurityConfig diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index 6bc975d16c22..8c2a46760810 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -14,11 +14,13 @@ namespace CodeIgniter\Security; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Security\Exceptions\SecurityException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockAppConfig; use CodeIgniter\Test\Mock\MockSecurity; @@ -38,9 +40,9 @@ protected function setUp(): void { parent::setUp(); - $_COOKIE = []; - $this->resetServices(); + + Services::injectMock('superglobals', new Superglobals(null, null, null, [])); } private static function createMockSecurity(SecurityConfig $config = new SecurityConfig()): MockSecurity @@ -72,7 +74,7 @@ public function testBasicConfigIsSaved(): void public function testHashIsReadFromCookie(): void { - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals')->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $security = $this->createMockSecurity(); @@ -86,7 +88,7 @@ public function testGetHashSetsCookieWhenGETWithoutCSRFCookie(): void { $security = $this->createMockSecurity(); - $_SERVER['REQUEST_METHOD'] = 'GET'; + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); $security->verify(new Request(new MockAppConfig())); @@ -96,21 +98,23 @@ public function testGetHashSetsCookieWhenGETWithoutCSRFCookie(): void public function testGetHashReturnsCSRFCookieWhenGETWithCSRFCookie(): void { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'GET') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $security = $this->createMockSecurity(); $security->verify(new Request(new MockAppConfig())); - $this->assertSame($_COOKIE['csrf_cookie_name'], $security->getHash()); + $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $security->getHash()); } public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -121,10 +125,11 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void public function testCSRFVerifyPostReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('foo', 'bar') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -132,13 +137,14 @@ public function testCSRFVerifyPostReturnsSelfOnMatch(): void $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, $_POST); + $this->assertCount(1, service('superglobals')->getPostArray()); } public function testCSRFVerifyHeaderThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -151,9 +157,10 @@ public function testCSRFVerifyHeaderThrowsExceptionOnNoMatch(): void public function testCSRFVerifyHeaderReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['foo'] = 'bar'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('foo', 'bar') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -163,13 +170,14 @@ public function testCSRFVerifyHeaderReturnsSelfOnMatch(): void $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, $_POST); + $this->assertCount(1, service('superglobals')->getPostArray()); } public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -184,8 +192,9 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void public function testCSRFVerifyJsonReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -202,8 +211,9 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void public function testCSRFVerifyPutBodyThrowsExceptionOnNoMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'PUT') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005b'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -218,8 +228,9 @@ public function testCSRFVerifyPutBodyThrowsExceptionOnNoMatch(): void public function testCSRFVerifyPutBodyReturnsSelfOnMatch(): void { - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'PUT') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $security = $this->createMockSecurity(); $request = $this->createIncomingRequest(); @@ -245,9 +256,10 @@ public function testSanitizeFilename(): void public function testRegenerateWithFalseSecurityRegenerateProperty(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $config = new SecurityConfig(); $config->regenerate = false; @@ -265,9 +277,10 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void public function testRegenerateWithFalseSecurityRegeneratePropertyManually(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $config = new SecurityConfig(); $config->regenerate = false; @@ -286,9 +299,10 @@ public function testRegenerateWithFalseSecurityRegeneratePropertyManually(): voi public function testRegenerateWithTrueSecurityRegenerateProperty(): void { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); $config = new SecurityConfig(); $config->regenerate = true; @@ -317,16 +331,15 @@ public function testGetters(): void public function testGetPostedTokenReturnsTokenFromPost(): void { - $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $request = $this->createIncomingRequest(); - $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); + service('superglobals')->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a'); + $request = $this->createIncomingRequest(); + $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); $this->assertSame('8b9218a55906f9dcc1dc263dce7f005a', $method($request)); } public function testGetPostedTokenReturnsTokenFromHeader(): void { - $_POST = []; $request = $this->createIncomingRequest()->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); @@ -335,7 +348,6 @@ public function testGetPostedTokenReturnsTokenFromHeader(): void public function testGetPostedTokenReturnsTokenFromJsonBody(): void { - $_POST = []; $jsonBody = json_encode(['csrf_test_name' => '8b9218a55906f9dcc1dc263dce7f005a']); $request = $this->createIncomingRequest()->setBody($jsonBody); $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); @@ -345,7 +357,6 @@ public function testGetPostedTokenReturnsTokenFromJsonBody(): void public function testGetPostedTokenReturnsTokenFromFormBody(): void { - $_POST = []; $formBody = 'csrf_test_name=8b9218a55906f9dcc1dc263dce7f005a'; $request = $this->createIncomingRequest()->setBody($formBody); $method = self::getPrivateMethodInvoker($this->createMockSecurity(), 'getPostedToken'); diff --git a/tests/system/Session/SessionTest.php b/tests/system/Session/SessionTest.php index dd3272405d8a..3b7cd3860ccd 100644 --- a/tests/system/Session/SessionTest.php +++ b/tests/system/Session/SessionTest.php @@ -14,8 +14,10 @@ namespace CodeIgniter\Session; use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; use CodeIgniter\Cookie\Exceptions\CookieException; use CodeIgniter\Session\Handlers\FileHandler; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockSession; use CodeIgniter\Test\TestLogger; @@ -40,8 +42,9 @@ protected function setUp(): void { parent::setUp(); - $_COOKIE = []; $_SESSION = []; + + Services::injectMock('superglobals', new Superglobals(null, null, null, [])); } /** @@ -157,8 +160,8 @@ public function testGetReturnsNullWhenNotFound(): void public function testGetReturnsNullWhenNotFoundWithXmlHttpRequest(): void { - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'xmlhttprequest'; - $_SESSION = []; + service('superglobals')->setServer('HTTP_X_REQUESTED_WITH', 'xmlhttprequest'); + $_SESSION = []; $session = $this->getInstance(); $session->start(); @@ -168,8 +171,8 @@ public function testGetReturnsNullWhenNotFoundWithXmlHttpRequest(): void public function testGetReturnsEmptyArrayWhenWithXmlHttpRequest(): void { - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'xmlhttprequest'; - $_SESSION = []; + service('superglobals')->setServer('HTTP_X_REQUESTED_WITH', 'xmlhttprequest'); + $_SESSION = []; $session = $this->getInstance(); $session->start(); diff --git a/tests/system/SuperglobalsTest.php b/tests/system/SuperglobalsTest.php index 904e3e913d21..b7a2af0edcd7 100644 --- a/tests/system/SuperglobalsTest.php +++ b/tests/system/SuperglobalsTest.php @@ -13,21 +13,537 @@ namespace CodeIgniter; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[BackupGlobals(true)] #[Group('Others')] final class SuperglobalsTest extends CIUnitTestCase { - public function testSetGet(): void + private Superglobals $superglobals; + + protected function setUp(): void + { + parent::setUp(); + + $this->superglobals = new Superglobals([], [], [], [], [], []); + } + + // $_SERVER tests + public function testServerGetSet(): void + { + $this->superglobals->setServer('TEST_KEY', 'test_value'); + + $this->assertSame('test_value', $this->superglobals->server('TEST_KEY')); + $this->assertSame('test_value', $_SERVER['TEST_KEY']); + } + + public function testServerGetReturnsNullForNonExistent(): void + { + $this->assertNull($this->superglobals->server('NON_EXISTENT_KEY')); + } + + public function testServerSetWithArray(): void + { + $this->superglobals->setServer('argv', ['arg1', 'arg2']); + + $this->assertSame(['arg1', 'arg2'], $this->superglobals->server('argv')); + } + + public function testServerSetWithInt(): void + { + $this->superglobals->setServer('REQUEST_TIME', 1234567890); + + $this->assertSame(1234567890, $this->superglobals->server('REQUEST_TIME')); + } + + public function testServerSetWithFloat(): void + { + $this->superglobals->setServer('REQUEST_TIME_FLOAT', 1234567890.123); + + $this->assertEqualsWithDelta(1234567890.123, $this->superglobals->server('REQUEST_TIME_FLOAT'), PHP_FLOAT_EPSILON); + } + + public function testServerUnset(): void + { + $this->superglobals->setServer('TEST_KEY', 'value'); + $this->superglobals->unsetServer('TEST_KEY'); + + $this->assertNull($this->superglobals->server('TEST_KEY')); + $this->assertArrayNotHasKey('TEST_KEY', $_SERVER); + } + + public function testServerGetArray(): void + { + $this->superglobals->setServer('KEY1', 'value1'); + $this->superglobals->setServer('KEY2', 'value2'); + + $array = $this->superglobals->getServerArray(); + + $this->assertArrayHasKey('KEY1', $array); + $this->assertArrayHasKey('KEY2', $array); + $this->assertSame('value1', $array['KEY1']); + $this->assertSame('value2', $array['KEY2']); + } + + public function testServerSetArray(): void + { + $data = ['KEY1' => 'value1', 'KEY2' => 'value2']; + + $this->superglobals->setServerArray($data); + + $this->assertSame('value1', $this->superglobals->server('KEY1')); + $this->assertSame('value2', $this->superglobals->server('KEY2')); + $this->assertSame($data, $_SERVER); + } + + // $_GET tests + public function testGetGetSet(): void + { + $this->superglobals->setGet('test', 'value1'); + + $this->assertSame('value1', $this->superglobals->get('test')); + $this->assertSame('value1', $_GET['test']); + } + + public function testGetReturnsNullForNonExistent(): void + { + $this->assertNull($this->superglobals->get('non_existent')); + } + + public function testGetSetWithArray(): void + { + $this->superglobals->setGet('colors', ['red', 'blue']); + + $this->assertSame(['red', 'blue'], $this->superglobals->get('colors')); + } + + public function testGetUnset(): void + { + $this->superglobals->setGet('test', 'value'); + $this->superglobals->unsetGet('test'); + + $this->assertNull($this->superglobals->get('test')); + $this->assertArrayNotHasKey('test', $_GET); + } + + public function testGetGetArray(): void + { + $this->superglobals->setGet('key1', 'value1'); + $this->superglobals->setGet('key2', 'value2'); + + $array = $this->superglobals->getGetArray(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $array); + } + + public function testGetSetArray(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + + $this->superglobals->setGetArray($data); + + $this->assertSame('value1', $this->superglobals->get('key1')); + $this->assertSame($data, $_GET); + } + + // $_POST tests + public function testPostGetSet(): void + { + $this->superglobals->setPost('test', 'value1'); + + $this->assertSame('value1', $this->superglobals->post('test')); + $this->assertSame('value1', $_POST['test']); + } + + public function testPostReturnsNullForNonExistent(): void + { + $this->assertNull($this->superglobals->post('non_existent')); + } + + public function testPostSetWithArray(): void + { + $this->superglobals->setPost('user', ['name' => 'John', 'age' => '30']); + + $this->assertSame(['name' => 'John', 'age' => '30'], $this->superglobals->post('user')); + } + + public function testPostUnset(): void + { + $this->superglobals->setPost('test', 'value'); + $this->superglobals->unsetPost('test'); + + $this->assertNull($this->superglobals->post('test')); + $this->assertArrayNotHasKey('test', $_POST); + } + + public function testPostGetArray(): void + { + $this->superglobals->setPost('key1', 'value1'); + $this->superglobals->setPost('key2', 'value2'); + + $array = $this->superglobals->getPostArray(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $array); + } + + public function testPostSetArray(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + + $this->superglobals->setPostArray($data); + + $this->assertSame('value1', $this->superglobals->post('key1')); + $this->assertSame($data, $_POST); + } + + // $_COOKIE tests + public function testCookieGetSet(): void + { + $this->superglobals->setCookie('session', 'abc123'); + + $this->assertSame('abc123', $this->superglobals->cookie('session')); + $this->assertSame('abc123', $_COOKIE['session']); + } + + public function testCookieReturnsNullForNonExistent(): void + { + $this->assertNull($this->superglobals->cookie('non_existent')); + } + + public function testCookieSetWithArray(): void + { + $this->superglobals->setCookie('data', ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $this->superglobals->cookie('data')); + } + + public function testCookieUnset(): void + { + $this->superglobals->setCookie('test', 'value'); + $this->superglobals->unsetCookie('test'); + + $this->assertNull($this->superglobals->cookie('test')); + $this->assertArrayNotHasKey('test', $_COOKIE); + } + + public function testCookieGetArray(): void + { + $this->superglobals->setCookie('key1', 'value1'); + $this->superglobals->setCookie('key2', 'value2'); + + $array = $this->superglobals->getCookieArray(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $array); + } + + public function testCookieSetArray(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + + $this->superglobals->setCookieArray($data); + + $this->assertSame('value1', $this->superglobals->cookie('key1')); + $this->assertSame($data, $_COOKIE); + } + + // $_REQUEST tests + public function testRequestGetSet(): void + { + $this->superglobals->setRequest('test', 'value1'); + + $this->assertSame('value1', $this->superglobals->request('test')); + $this->assertSame('value1', $_REQUEST['test']); + } + + public function testRequestReturnsNullForNonExistent(): void { - $globals = new Superglobals([], []); + $this->assertNull($this->superglobals->request('non_existent')); + } - $globals->setGet('test', 'value1'); + public function testRequestSetWithArray(): void + { + $this->superglobals->setRequest('data', ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $this->superglobals->request('data')); + } + + public function testRequestUnset(): void + { + $this->superglobals->setRequest('test', 'value'); + $this->superglobals->unsetRequest('test'); + + $this->assertNull($this->superglobals->request('test')); + $this->assertArrayNotHasKey('test', $_REQUEST); + } + + public function testRequestGetArray(): void + { + $this->superglobals->setRequest('key1', 'value1'); + $this->superglobals->setRequest('key2', 'value2'); + + $array = $this->superglobals->getRequestArray(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $array); + } + + public function testRequestSetArray(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + + $this->superglobals->setRequestArray($data); + + $this->assertSame('value1', $this->superglobals->request('key1')); + $this->assertSame($data, $_REQUEST); + } + + // $_FILES tests + public function testFilesGetArray(): void + { + $filesData = [ + 'upload' => [ + 'name' => 'document.pdf', + 'type' => 'application/pdf', + 'tmp_name' => '/tmp/phpTest', + 'error' => UPLOAD_ERR_OK, + 'size' => 12345, + ], + ]; + + $this->superglobals->setFilesArray($filesData); + + $this->assertSame($filesData, $this->superglobals->getFilesArray()); + $this->assertSame($filesData, $_FILES); + } + + public function testFilesSetArrayWithMultipleFiles(): void + { + $filesData = [ + 'photos' => [ + 'name' => ['photo1.jpg', 'photo2.jpg'], + 'type' => ['image/jpeg', 'image/jpeg'], + 'tmp_name' => ['/tmp/phpA', '/tmp/phpB'], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK], + 'size' => [1234, 5678], + ], + ]; + + $this->superglobals->setFilesArray($filesData); + + $this->assertSame($filesData, $this->superglobals->getFilesArray()); + $this->assertSame($filesData, $_FILES); + } + + public function testFilesSetArrayEmpty(): void + { + $this->superglobals->setFilesArray([ + 'upload' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/test', + 'error' => UPLOAD_ERR_OK, + 'size' => 100, + ], + ]); + + // Reset to empty + $this->superglobals->setFilesArray([]); + + $this->assertSame([], $this->superglobals->getFilesArray()); + $this->assertSame([], $_FILES); + } + + // Generic methods + public function testGetGlobalArray(): void + { + $this->superglobals->setGet('test', 'value'); + + $this->assertSame(['test' => 'value'], $this->superglobals->getGlobalArray('get')); + } + + public function testGetGlobalArrayForFiles(): void + { + $filesData = [ + 'upload' => [ + 'name' => 'test.pdf', + 'type' => 'application/pdf', + 'tmp_name' => '/tmp/phpTest', + 'error' => UPLOAD_ERR_OK, + 'size' => 999, + ], + ]; + + $this->superglobals->setFilesArray($filesData); + + $this->assertSame($filesData, $this->superglobals->getGlobalArray('files')); + } + + public function testGetGlobalArrayThrowsExceptionForInvalidName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid superglobal name 'invalid'. Must be one of: server, get, post, cookie, files, request."); + + $this->superglobals->getGlobalArray('invalid'); + } + + public function testSetGlobalArray(): void + { + $data = ['key' => 'value']; + + $this->superglobals->setGlobalArray('post', $data); + + $this->assertSame('value', $this->superglobals->post('key')); + $this->assertSame($data, $_POST); + } + + public function testSetGlobalArrayForFiles(): void + { + $filesData = [ + 'doc' => [ + 'name' => 'file.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/test', + 'error' => UPLOAD_ERR_OK, + 'size' => 555, + ], + ]; + + $this->superglobals->setGlobalArray('files', $filesData); + + $this->assertSame($filesData, $this->superglobals->getFilesArray()); + $this->assertSame($filesData, $_FILES); + } + + public function testSetGlobalArrayThrowsExceptionForInvalidName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid superglobal name 'invalid'. Must be one of: server, get, post, cookie, files, request."); + + $this->superglobals->setGlobalArray('invalid', ['key' => 'value']); + } + + // Constructor tests + public function testConstructorWithCustomArrays(): void + { + $server = ['SERVER_KEY' => 'server_value']; + $get = ['get_key' => 'get_value']; + $post = ['post_key' => 'post_value']; + $cookie = ['cookie_key' => 'cookie_value']; + $request = ['request_key' => 'request_value']; + $files = [ + 'upload' => [ + 'name' => 'custom.pdf', + 'type' => 'application/pdf', + 'tmp_name' => '/tmp/custom', + 'error' => UPLOAD_ERR_OK, + 'size' => 7777, + ], + ]; + + $superglobals = new Superglobals($server, $get, $post, $cookie, $files, $request); + + $this->assertSame('server_value', $superglobals->server('SERVER_KEY')); + $this->assertSame('get_value', $superglobals->get('get_key')); + $this->assertSame('post_value', $superglobals->post('post_key')); + $this->assertSame('cookie_value', $superglobals->cookie('cookie_key')); + $this->assertSame('request_value', $superglobals->request('request_key')); + $this->assertSame($files, $superglobals->getFilesArray()); + } + + public function testConstructorSynchronizesWithPhpSuperglobals(): void + { + $server = ['CUSTOM_SERVER' => 'server_val']; + $get = ['custom_get' => 'get_val']; + $post = ['custom_post' => 'post_val']; + $cookie = ['custom_cookie' => 'cookie_val']; + $request = ['custom_request' => 'request_val']; + $files = [ + 'doc' => [ + 'name' => 'test.pdf', + 'type' => 'application/pdf', + 'tmp_name' => '/tmp/test', + 'error' => UPLOAD_ERR_OK, + 'size' => 999, + ], + ]; + + new Superglobals($server, $get, $post, $cookie, $files, $request); + + // Verify PHP superglobals are synchronized + $this->assertSame('server_val', $_SERVER['CUSTOM_SERVER']); + $this->assertSame('get_val', $_GET['custom_get']); + $this->assertSame('post_val', $_POST['custom_post']); + $this->assertSame('cookie_val', $_COOKIE['custom_cookie']); + $this->assertSame('request_val', $_REQUEST['custom_request']); + $this->assertSame($files, $_FILES); + } + + // Fluent API tests + public function testFluentApiMethodChaining(): void + { + $result = $this->superglobals + ->setServer('KEY1', 'value1') + ->setGet('KEY2', 'value2') + ->setPost('KEY3', 'value3') + ->setCookie('KEY4', 'value4') + ->setRequest('KEY5', 'value5'); + + $this->assertInstanceOf(Superglobals::class, $result); + $this->assertSame('value1', $this->superglobals->server('KEY1')); + $this->assertSame('value2', $this->superglobals->get('KEY2')); + $this->assertSame('value3', $this->superglobals->post('KEY3')); + $this->assertSame('value4', $this->superglobals->cookie('KEY4')); + $this->assertSame('value5', $this->superglobals->request('KEY5')); + } + + public function testFluentApiWithUnset(): void + { + $result = $this->superglobals + ->setServer('KEY1', 'value1') + ->setServer('KEY2', 'value2') + ->setGet('KEY3', 'value3') + ->unsetServer('KEY1') + ->unsetGet('KEY3'); + + $this->assertInstanceOf(Superglobals::class, $result); + $this->assertNull($this->superglobals->server('KEY1')); + $this->assertSame('value2', $this->superglobals->server('KEY2')); + $this->assertNull($this->superglobals->get('KEY3')); + } + + public function testFluentApiWithArraySetters(): void + { + $serverData = ['SERVER1' => 'val1', 'SERVER2' => 'val2']; + $getData = ['get1' => 'val3']; + + $result = $this->superglobals + ->setServerArray($serverData) + ->setGetArray($getData) + ->setPostArray(['post1' => 'val4']); + + $this->assertInstanceOf(Superglobals::class, $result); + $this->assertSame('val1', $this->superglobals->server('SERVER1')); + $this->assertSame('val2', $this->superglobals->server('SERVER2')); + $this->assertSame('val3', $this->superglobals->get('get1')); + $this->assertSame('val4', $this->superglobals->post('post1')); + } + + public function testFluentApiMixedOperations(): void + { + $result = $this->superglobals + ->setServerArray(['KEY1' => 'value1']) + ->setServer('KEY2', 'value2') + ->unsetServer('KEY1') + ->setGet('test', 'data'); - $this->assertSame('value1', $globals->get('test')); + $this->assertInstanceOf(Superglobals::class, $result); + $this->assertNull($this->superglobals->server('KEY1')); + $this->assertSame('value2', $this->superglobals->server('KEY2')); + $this->assertSame('data', $this->superglobals->get('test')); } } diff --git a/tests/system/Test/FeatureTestTraitTest.php b/tests/system/Test/FeatureTestTraitTest.php index c0e2e2c873d1..870572174204 100644 --- a/tests/system/Test/FeatureTestTraitTest.php +++ b/tests/system/Test/FeatureTestTraitTest.php @@ -22,12 +22,14 @@ use Config\App; use Config\Feature; use Config\Routing; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[BackupGlobals(true)] #[Group('DatabaseLive')] final class FeatureTestTraitTest extends CIUnitTestCase { diff --git a/tests/system/Validation/StrictRules/FileRulesTest.php b/tests/system/Validation/StrictRules/FileRulesTest.php index 5bb259b254ea..5f848c22db60 100644 --- a/tests/system/Validation/StrictRules/FileRulesTest.php +++ b/tests/system/Validation/StrictRules/FileRulesTest.php @@ -58,7 +58,7 @@ protected function setUp(): void $this->validation = new Validation((object) $this->config, service('renderer')); $this->validation->reset(); - $_FILES = [ + service('superglobals')->setFilesArray([ 'avatar' => [ 'tmp_name' => TESTPATH . '_support/Validation/uploads/phpUxc0ty', 'name' => 'my-avatar.png', @@ -146,7 +146,13 @@ protected function setUp(): void 400, ], ], - ]; + ]); + } + + protected function tearDown(): void + { + parent::tearDown(); + service('superglobals')->setFilesArray([]); } public function testUploadedTrue(): void diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index b015ab50f363..386266f44391 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -13,11 +13,13 @@ namespace CodeIgniter\Validation; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Validation\Exceptions\ValidationException; use Config\App; @@ -82,6 +84,9 @@ class ValidationTest extends CIUnitTestCase protected function setUp(): void { parent::setUp(); + + Services::injectMock('superglobals', new Superglobals()); + $this->validation = new Validation((object) static::$config, service('renderer')); $this->validation->reset(); } @@ -869,7 +874,7 @@ public function testRawInput(): void public function testJsonInput(): void { - $_SERVER['CONTENT_TYPE'] = 'application/json'; + service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); $data = [ 'username' => 'admin001', @@ -893,7 +898,7 @@ public function testJsonInput(): void $this->assertSame([], $this->validation->getErrors()); $this->assertSame(['role' => 'administrator'], $this->validation->getValidated()); - unset($_SERVER['CONTENT_TYPE']); + service('superglobals')->unsetServer('CONTENT_TYPE'); } public function testJsonInputInvalid(): void @@ -949,7 +954,7 @@ public function testJsonInputObjectArray(): void } EOL; - $_SERVER['CONTENT_TYPE'] = 'application/json'; + service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); $config = new App(); $config->baseURL = 'http://example.com/'; @@ -967,7 +972,7 @@ public function testJsonInputObjectArray(): void $this->assertFalse($result); $this->assertSame(['p' => 'Validation.array_count'], $this->validation->getErrors()); - unset($_SERVER['CONTENT_TYPE']); + service('superglobals')->unsetServer('CONTENT_TYPE'); } public function testHasRule(): void @@ -1214,7 +1219,7 @@ public function testRulesForSingleRuleWithAsteriskWillReturnNoError(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $_REQUEST = [ + service('superglobals')->setRequestArray([ 'id_user' => [ 1, 3, @@ -1223,7 +1228,7 @@ public function testRulesForSingleRuleWithAsteriskWillReturnNoError(): void 'abc123', 'xyz098', ], - ]; + ]); $request = new IncomingRequest($config, new SiteURI($config), 'php://input', new UserAgent()); @@ -1241,7 +1246,7 @@ public function testRulesForSingleRuleWithAsteriskWillReturnError(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $_REQUEST = [ + service('superglobals')->setRequestArray([ 'id_user' => [ '1dfd', 3, @@ -1257,7 +1262,7 @@ public function testRulesForSingleRuleWithAsteriskWillReturnError(): void ['name' => 'John'], ], ], - ]; + ]); $request = new IncomingRequest($config, new SiteURI($config), 'php://input', new UserAgent()); @@ -1291,9 +1296,9 @@ public function testRulesForSingleRuleWithSingleValue(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $_REQUEST = [ + service('superglobals')->setRequestArray([ 'id_user' => 'gh', - ]; + ]); $request = new IncomingRequest($config, new SiteURI($config), 'php://input', new UserAgent()); diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index 229b86cc1e8c..b72e9130e480 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,4 +1,4 @@ -# total 83 errors +# total 84 errors parameters: ignoreErrors: @@ -122,6 +122,11 @@ parameters: count: 1 path: ../../tests/system/HTTP/HeaderTest.php + - + message: '#^Parameter \#1 \$array of method CodeIgniter\\Superglobals\:\:setServerArray\(\) expects array\\|float\|int\|string\>, array\ given\.$#' + count: 1 + path: ../../tests/system/HTTP/MessageTest.php + - message: '#^Parameter \#3 \$expire of method CodeIgniter\\HTTP\\Response\:\:setCookie\(\) expects int, string given\.$#' count: 1 diff --git a/utils/phpstan-baseline/codeigniter.getReassignArray.neon b/utils/phpstan-baseline/codeigniter.getReassignArray.neon index 6c45e13961e4..f663c67ee50e 100644 --- a/utils/phpstan-baseline/codeigniter.getReassignArray.neon +++ b/utils/phpstan-baseline/codeigniter.getReassignArray.neon @@ -1,37 +1,12 @@ -# total 18 errors +# total 3 errors parameters: ignoreErrors: - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 3 - path: ../../tests/system/CommonFunctionsTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 2 - path: ../../tests/system/Filters/InvalidCharsTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/CLIRequestTest.php - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/RedirectResponseTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/RequestTest.php - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' count: 1 @@ -40,19 +15,4 @@ parameters: - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/Log/LoggerTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/Models/PaginateModelTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 5 - path: ../../tests/system/Pager/PagerTest.php + path: ../../tests/system/Helpers/FormHelperTest.php diff --git a/utils/phpstan-baseline/codeigniter.superglobalAccess.neon b/utils/phpstan-baseline/codeigniter.superglobalAccess.neon index ab837de286c5..68f9316f8ee1 100644 --- a/utils/phpstan-baseline/codeigniter.superglobalAccess.neon +++ b/utils/phpstan-baseline/codeigniter.superglobalAccess.neon @@ -1,4 +1,4 @@ -# total 59 errors +# total 29 errors parameters: ignoreErrors: @@ -17,16 +17,6 @@ parameters: count: 1 path: ../../system/Commands/Encryption/GenerateKey.php - - - message: '#^Accessing offset ''CI_ENVIRONMENT'' directly on \$_SERVER is discouraged\.$#' - count: 3 - path: ../../system/Commands/Utilities/Environment.php - - - - message: '#^Accessing offset ''HTTP_HOST'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Commands/Utilities/Routes.php - - message: '#^Accessing offset ''REMOTE_ADDR'' directly on \$_SERVER is discouraged\.$#' count: 1 @@ -58,74 +48,14 @@ parameters: path: ../../system/Config/DotEnv.php - - message: '#^Accessing offset ''SERVER_PROTOCOL'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Config/Services.php - - - - message: '#^Accessing offset ''HTTP_USER_AGENT'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../system/HTTP/DownloadResponse.php - - - - message: '#^Accessing offset ''HTTPS'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../system/HTTP/IncomingRequest.php - - - - message: '#^Accessing offset array\|string directly on \$_GET is discouraged\.$#' - count: 2 - path: ../../system/HTTP/IncomingRequest.php - - - - message: '#^Accessing offset ''CONTENT_TYPE'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/HTTP/Message.php - - - - message: '#^Accessing offset \(int\|string\) directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/HTTP/Message.php - - - - message: '#^Accessing offset ''REQUEST_METHOD'' directly on \$_SERVER is discouraged\.$#' - count: 3 - path: ../../system/HTTP/Response.php - - - - message: '#^Accessing offset ''SERVER_PROTOCOL'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/HTTP/Response.php - - - - message: '#^Accessing offset ''SERVER_SOFTWARE'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../system/HTTP/Response.php - - - - message: '#^Accessing offset mixed directly on \$_GET is discouraged\.$#' - count: 1 - path: ../../system/Pager/Pager.php - - - - message: '#^Accessing offset ''REQUEST_METHOD'' directly on \$_SERVER is discouraged\.$#' + message: '#^Accessing offset string directly on \$_GET is discouraged\.$#' count: 1 - path: ../../system/Router/Router.php + path: ../../system/Superglobals.php - - message: '#^Accessing offset ''HTTP_X_REQUESTED_WITH'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../system/Session/Session.php - - - - message: '#^Accessing offset ''app\.baseURL'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CLI/ConsoleTest.php - - - - message: '#^Accessing offset ''encryption\.key'' directly on \$_SERVER is discouraged\.$#' + message: '#^Accessing offset string directly on \$_SERVER is discouraged\.$#' count: 1 - path: ../../tests/system/Commands/GenerateKeyTest.php + path: ../../system/Superglobals.php - message: '#^Accessing offset ''foo'' directly on \$_SERVER is discouraged\.$#' @@ -167,47 +97,27 @@ parameters: count: 1 path: ../../tests/system/Debug/ExceptionsTest.php - - - message: '#^Accessing offset ''encryption\.key'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Encryption/EncryptionTest.php - - - - message: '#^Accessing offset ''HTTP_USER_AGENT'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/DownloadResponseTest.php - - - - message: '#^Accessing offset ''SERVER_SOFTWARE'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/ResponseTest.php - - message: '#^Accessing offset ''QUERY_STRING'' directly on \$_SERVER is discouraged\.$#' count: 1 path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - message: '#^Accessing offset ''HTTP_HOST'' directly on \$_SERVER is discouraged\.$#' + message: '#^Accessing offset ''CUSTOM_SERVER'' directly on \$_SERVER is discouraged\.$#' count: 1 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php + path: ../../tests/system/SuperglobalsTest.php - - message: '#^Accessing offset ''REQUEST_URI'' directly on \$_SERVER is discouraged\.$#' + message: '#^Accessing offset ''TEST_KEY'' directly on \$_SERVER is discouraged\.$#' count: 1 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php + path: ../../tests/system/SuperglobalsTest.php - - message: '#^Accessing offset ''page'' directly on \$_GET is discouraged\.$#' + message: '#^Accessing offset ''custom_get'' directly on \$_GET is discouraged\.$#' count: 1 - path: ../../tests/system/Pager/PagerTest.php + path: ../../tests/system/SuperglobalsTest.php - - message: '#^Accessing offset ''page_foo'' directly on \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Accessing offset ''CONTENT_TYPE'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Validation/ValidationTest.php + message: '#^Accessing offset ''test'' directly on \$_GET is discouraged\.$#' + count: 1 + path: ../../tests/system/SuperglobalsTest.php diff --git a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon b/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon index c557d777faf5..ab0d015d5677 100644 --- a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon +++ b/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon @@ -1,922 +1,22 @@ -# total 423 errors +# total 5 errors parameters: ignoreErrors: - - - message: '#^Assigning string directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Commands/Utilities/Routes.php - - message: '#^Assigning string directly on offset string of \$_SERVER is discouraged\.$#' count: 1 path: ../../system/Config/DotEnv.php - - - message: '#^Assigning ''http\://example\.com/'' directly on offset ''app\.baseURL'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CLI/ConsoleTest.php - - - - message: '#^Assigning ''/'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''/cannotFound'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''/cli'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''/example'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 6 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''/image'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 20 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''/pages/about'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 8 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''/test'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''CLI'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 4 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''HTTP/1\.1'' directly on offset ''SERVER_PROTOCOL'' of \$_SERVER is discouraged\.$#' - count: 8 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''HTTP/2\.0'' directly on offset ''SERVER_PROTOCOL'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''HTTP/3\.0'' directly on offset ''SERVER_PROTOCOL'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''public/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning non\-falsy\-string directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - - - message: '#^Assigning ''production'' directly on offset ''CI_ENVIRONMENT'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Commands/EnvironmentCommandTest.php - - - - message: '#^Assigning string directly on offset ''CI_ENVIRONMENT'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Commands/EnvironmentCommandTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 4 - path: ../../tests/system/CommonFunctionsTest.php - - - - message: '#^Assigning ''bar'' directly on offset ''foo'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CommonFunctionsTest.php - - - - message: '#^Assigning ''TT'' directly on offset ''SER_VAR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Config/DotEnvTest.php - - message: '#^Assigning ''1'' directly on offset ''CODEIGNITER_SCREAM_DEPRECATIONS'' of \$_SERVER is discouraged\.$#' count: 1 path: ../../tests/system/Debug/ExceptionsTest.php - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Filters/DebugToolbarTest.php - - - - message: '#^Assigning ''DELETE'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Filters/FiltersTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 38 - path: ../../tests/system/Filters/FiltersTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Filters/HoneypotTest.php - - - - message: '#^Assigning string directly on offset ''val'' of \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/Filters/InvalidCharsTest.php - - - - message: '#^Assigning ''baz'' directly on offset ''bar'' of \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/CLIRequestTest.php - - - - message: '#^Assigning ''10'' directly on offset ''HTTP_CONTENT_LENGTH'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/CURLRequestTest.php - - - - message: '#^Assigning ''en\-US'' directly on offset ''HTTP_ACCEPT_LANGUAGE'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/CURLRequestTest.php - - - - message: '#^Assigning ''gzip, deflate, br'' directly on offset ''HTTP_ACCEPT_ENCODING'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/CURLRequestTest.php - - - - message: '#^Assigning ''site1\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/CURLRequestTest.php - - - - message: '#^Assigning ''Mozilla/5\.0 \(Linux; U; Android 2\.0\.3; ja\-jp; SC\-02C Build/IML74K\) AppleWebKit/534\.30 \(KHTML, like Gecko\) Version/4\.0 Mobile Safari/534\.30'' directly on offset ''HTTP_USER_AGENT'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/DownloadResponseTest.php - - - - message: '#^Assigning ''10\.0\.1\.200'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''10\.10\.1\.200'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''123\.123\.123\.123'' directly on offset ''HTTP_X_FORWARDED_FOR'' of \$_SERVER is discouraged\.$#' - count: 7 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''123\.123\.123\.123'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''123\.456\.23\.123'' directly on offset ''HTTP_X_FORWARDED_FOR'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''192\.168\.5\.21'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''2001\:db8\:1234\:ffff\:ffff\:ffff\:ffff\:ffff'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''2001\:db8\:1235\:ffff\:ffff\:ffff\:ffff\:ffff'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''2001\:db8\:\:2\:1'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''2001\:db8\:\:2\:2'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''2001\:xyz\:\:1'' directly on offset ''HTTP_X_FORWARDED_FOR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''3'' directly on offset ''TEST'' of \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''3'' directly on offset ''get'' of \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''fr; q\=1\.0, en; q\=0\.5'' directly on offset ''HTTP_ACCEPT_LANGUAGE'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''fr\-FR; q\=1\.0, en; q\=0\.5'' directly on offset ''HTTP_ACCEPT_LANGUAGE'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''on'' directly on offset ''HTTPS'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning 3 directly on offset ''TEST'' of \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning 5 directly on offset ''TEST'' of \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/RedirectResponseTest.php - - - - message: '#^Assigning ''http\://somewhere\.com'' directly on offset ''HTTP_REFERER'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/RedirectResponseTest.php - - - - message: '#^Assigning ''10\.0\.1\.200'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/RequestTest.php - - - - message: '#^Assigning ''10\.10\.1\.200'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/RequestTest.php - - - - message: '#^Assigning ''123\.123\.123\.123'' directly on offset ''HTTP_X_FORWARDED_FOR'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/HTTP/RequestTest.php - - - - message: '#^Assigning ''123\.123\.123\.123'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/RequestTest.php - - - - message: '#^Assigning ''123\.456\.23\.123'' directly on offset ''HTTP_X_FORWARDED_FOR'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/RequestTest.php - - - - message: '#^Assigning ''192\.168\.5\.21'' directly on offset ''REMOTE_ADDR'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/RequestTest.php - - - - message: '#^Assigning ''baz'' directly on offset ''bar'' of \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/RequestTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/ResponseTest.php - - - - message: '#^Assigning ''HTTP/1\.1'' directly on offset ''SERVER_PROTOCOL'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/HTTP/ResponseTest.php - - - - message: '#^Assigning ''Microsoft\-IIS'' directly on offset ''SERVER_SOFTWARE'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/ResponseTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/ResponseTest.php - - - - message: '#^Assigning string directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/ResponseTest.php - - - - message: '#^Assigning string directly on offset ''SERVER_PROTOCOL'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/ResponseTest.php - - - - message: '#^Assigning string directly on offset ''SERVER_SOFTWARE'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/ResponseTest.php - - - - message: '#^Assigning '''' directly on offset ''/ci/woot'' of \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/\?/ci/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/candy/snickers'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/ci/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/ci/index\.php/popcorn/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/ci/woot'' directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/ci/woot\?code\=good'' directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/ci431/public/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/ci431/public/index\.php/woot\?code\=good'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/fruits/banana'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 13 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php/fruits/banana'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 4 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php/woot\?code\=good'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php\?'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php\?/ci/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php\?/ci/woot\?code\=good'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/sub/example'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/sub/folder/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/sub/folder/index\.php/fruits/banana'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/sub/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/woot'' directly on offset ''PATH_INFO'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/woot'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''good'' directly on offset ''/ci/woot\?code'' of \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning string directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning string directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Assigning ''/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Assigning ''/index\.php/woot\?code\=good'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Assigning ''/woot'' directly on offset ''PATH_INFO'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Assigning ''code\=good'' directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Assigning ''good'' directly on offset ''code'' of \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Assigning ''localhost\:8080'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Assigning ''users\.example\.jp'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryTest.php - - - - message: '#^Assigning '''' directly on offset ''QUERY_STRING'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/HTTP/URITest.php - - - - message: '#^Assigning ''/ci/v4/controller/method'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/HTTP/URITest.php - - - - message: '#^Assigning ''/ci/v4/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/HTTP/URITest.php - - - - message: '#^Assigning ''/ci/v4/index\.php/controller/method'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/URITest.php - - - - message: '#^Assigning ''/controller/method'' directly on offset ''PATH_INFO'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/HTTP/URITest.php - - - - message: '#^Assigning ''example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/HTTP/URITest.php - - - - message: '#^Assigning ''/'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/assets/image\.jpg'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/foo/public/bar\?baz\=quip'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/foo/public/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/public/'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 4 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/public/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/subfolder/assets/image\.jpg'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''/subfolder/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''8080'' directly on offset ''SERVER_PORT'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 11 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''invalid\.example\.org'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''www\.example\.jp'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning non\-falsy\-string directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Helpers/URLHelper/CurrentUrlTest.php - - - - message: '#^Assigning ''example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php - - - - message: '#^Assigning ''http\://example\.com/one\?two'' directly on offset ''HTTP_REFERER'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Helpers/URLHelper/MiscUrlTest.php - - - - message: '#^Assigning ''/ci/v4/x/y'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php - - - - message: '#^Assigning ''/public'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php - - - - message: '#^Assigning ''/public/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php - - - - message: '#^Assigning ''/test'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php - - - - message: '#^Assigning ''/test/page'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php - - - - message: '#^Assigning ''example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php - - - - message: '#^Assigning ''www\.example\.jp'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Helpers/URLHelper/SiteUrlTest.php - - message: '#^Assigning ''test'' directly on offset ''HTTPS'' of \$_SERVER is discouraged\.$#' count: 1 path: ../../tests/system/HomeTest.php - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Honeypot/HoneypotTest.php - - - - message: '#^Assigning ''/ci/v4/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning ''/ci/v4/x/y'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning ''/index\.php'' directly on offset ''SCRIPT_NAME'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning ''bar'' directly on offset ''foo'' of \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning ''example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning 2 directly on offset ''page'' of \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning 2 directly on offset ''page_foo'' of \$_GET is discouraged\.$#' - count: 5 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning 3 directly on offset ''page'' of \$_GET is discouraged\.$#' - count: 2 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning string directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Pager/PagerTest.php - - - - message: '#^Assigning ''/work'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''/work/1'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''/work/1/edit'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''/work/123'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''/work/new'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''DELETE'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 4 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''HTTP/1\.1'' directly on offset ''SERVER_PROTOCOL'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''PATCH'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''PUT'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourceControllerTest.php - - - - message: '#^Assigning ''/work'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''/work/create'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''/work/delete/123'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''/work/edit/1'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''/work/new'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''/work/remove/123'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''/work/show/1'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''/work/update/123'' directly on offset ''REQUEST_URI'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 5 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''HTTP/1\.1'' directly on offset ''SERVER_PROTOCOL'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/RESTful/ResourcePresenterTest.php - - - - message: '#^Assigning ''adm\.example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 4 - path: ../../tests/system/Router/RouteCollectionTest.php - - - - message: '#^Assigning ''dev\.example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Router/RouteCollectionTest.php - - - - message: '#^Assigning ''doc\.domain\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 5 - path: ../../tests/system/Router/RouteCollectionTest.php - - - - message: '#^Assigning ''doc\.example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Router/RouteCollectionTest.php - - - - message: '#^Assigning ''example\.co\.uk'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Router/RouteCollectionTest.php - - - - message: '#^Assigning ''example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 5 - path: ../../tests/system/Router/RouteCollectionTest.php - - - - message: '#^Assigning ''www\.example\.com'' directly on offset ''HTTP_HOST'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Router/RouteCollectionTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 10 - path: ../../tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php - - - - message: '#^Assigning ''PUT'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 8 - path: ../../tests/system/Security/SecurityCSRFSessionTest.php - - - - message: '#^Assigning ''PUT'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 3 - path: ../../tests/system/Security/SecurityCSRFSessionTest.php - - - - message: '#^Assigning ''GET'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Security/SecurityTest.php - - - - message: '#^Assigning ''POST'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 9 - path: ../../tests/system/Security/SecurityTest.php - - - - message: '#^Assigning ''PUT'' directly on offset ''REQUEST_METHOD'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Security/SecurityTest.php - - - - message: '#^Assigning ''xmlhttprequest'' directly on offset ''HTTP_X_REQUESTED_WITH'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Session/SessionTest.php - - message: '#^Assigning ''test'' directly on offset ''HTTPS'' of \$_SERVER is discouraged\.$#' count: 1 @@ -926,8 +26,3 @@ parameters: message: '#^Assigning ''test'' directly on offset ''HTTPS'' of \$_SERVER is discouraged\.$#' count: 1 path: ../../tests/system/Test/FeatureTestTraitTest.php - - - - message: '#^Assigning ''application/json'' directly on offset ''CONTENT_TYPE'' of \$_SERVER is discouraged\.$#' - count: 2 - path: ../../tests/system/Validation/ValidationTest.php diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index b278eddd203a..c198e3caaf9e 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 223 errors +# total 222 errors parameters: ignoreErrors: @@ -224,7 +224,7 @@ parameters: - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 2 + count: 1 path: ../../system/HTTP/IncomingRequest.php - diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 1c77572e3b3b..ff9fcad81fe2 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2639 errors +# total 2172 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index edeb2ece8387..84f4e89bebbb 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1314 errors +# total 1312 errors parameters: ignoreErrors: @@ -322,11 +322,31 @@ parameters: count: 1 path: ../../system/Config/Services.php + - + message: '#^Method CodeIgniter\\Config\\Services\:\:superglobals\(\) has parameter \$cookie with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Config/Services.php + + - + message: '#^Method CodeIgniter\\Config\\Services\:\:superglobals\(\) has parameter \$files with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Config/Services.php + - message: '#^Method CodeIgniter\\Config\\Services\:\:superglobals\(\) has parameter \$get with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Config/Services.php + - + message: '#^Method CodeIgniter\\Config\\Services\:\:superglobals\(\) has parameter \$post with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Config/Services.php + + - + message: '#^Method CodeIgniter\\Config\\Services\:\:superglobals\(\) has parameter \$request with no value type specified in iterable type array\.$#' + count: 1 + path: ../../system/Config/Services.php + - message: '#^Method CodeIgniter\\Config\\Services\:\:superglobals\(\) has parameter \$server with no value type specified in iterable type array\.$#' count: 1 @@ -4212,36 +4232,6 @@ parameters: count: 1 path: ../../system/Router/RouterInterface.php - - - message: '#^Method CodeIgniter\\Superglobals\:\:__construct\(\) has parameter \$get with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Superglobals.php - - - - message: '#^Method CodeIgniter\\Superglobals\:\:__construct\(\) has parameter \$server with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Superglobals.php - - - - message: '#^Method CodeIgniter\\Superglobals\:\:get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Superglobals.php - - - - message: '#^Method CodeIgniter\\Superglobals\:\:setGetArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Superglobals.php - - - - message: '#^Property CodeIgniter\\Superglobals\:\:\$get type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Superglobals.php - - - - message: '#^Property CodeIgniter\\Superglobals\:\:\$server type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Superglobals.php - - message: '#^Method CodeIgniter\\Test\\Constraints\\SeeInDatabase\:\:__construct\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 @@ -4968,52 +4958,52 @@ parameters: path: ../../system/View/Parser.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:313\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:315\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:336\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:338\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:362\:\:includeComments\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:364\:\:includeComments\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:362\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:364\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:394\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:396\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:417\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:419\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:445\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:447\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:497\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:499\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:556\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:558\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:579\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:581\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php From 516c50dcc712f37fd64819c709df49aebd1372f8 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 6 Jan 2026 12:45:09 +0800 Subject: [PATCH 64/84] refactor: Superglobals - remove property promotion and fix PHPDocs (#9871) --- system/Superglobals.php | 46 ++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/system/Superglobals.php b/system/Superglobals.php index 4acfe2c0d3ab..2175728e862e 100644 --- a/system/Superglobals.php +++ b/system/Superglobals.php @@ -42,6 +42,36 @@ */ final class Superglobals { + /** + * @var array + */ + private array $server = []; + + /** + * @var array + */ + private array $get = []; + + /** + * @var array + */ + private array $post = []; + + /** + * @var array + */ + private array $cookie = []; + + /** + * @var array + */ + private array $files = []; + + /** + * @var array + */ + private array $request = []; + /** * @param array|null $server * @param array|null $get @@ -51,12 +81,12 @@ final class Superglobals * @param array|null $request */ public function __construct( - private ?array $server = null, - private ?array $get = null, - private ?array $post = null, - private ?array $cookie = null, - private ?array $files = null, - private ?array $request = null, + ?array $server = null, + ?array $get = null, + ?array $post = null, + ?array $cookie = null, + ?array $files = null, + ?array $request = null, ) { $this ->setServerArray($server ?? $_SERVER) @@ -360,7 +390,7 @@ public function setRequestArray(array $array): self /** * Get all $_FILES values. * - * @return files_items + * @return array */ public function getFilesArray(): array { @@ -370,7 +400,7 @@ public function getFilesArray(): array /** * Set the entire $_FILES array. * - * @param files_items $array + * @param array $array */ public function setFilesArray(array $array): self { From 12cf2b43f4f4cd987fe9586dc1d26640084b9659 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 6 Jan 2026 09:21:59 +0100 Subject: [PATCH 65/84] feat: encryption key rotation (#9870) Co-authored-by: patel-vansh Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: Abdul Malik Ikhsan --- app/Config/Encryption.php | 17 ++ system/Config/BaseConfig.php | 35 ++- system/Encryption/Encryption.php | 4 + system/Encryption/Handlers/OpenSSLHandler.php | 8 +- system/Encryption/KeyRotationDecorator.php | 111 +++++++ .../Encryption/KeyRotationDecoratorTest.php | 283 ++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + .../source/libraries/encryption.rst | 49 +++ .../source/libraries/encryption/014.php | 17 ++ utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 17 +- utils/phpstan-baseline/property.notFound.neon | 12 +- 12 files changed, 545 insertions(+), 11 deletions(-) create mode 100644 system/Encryption/KeyRotationDecorator.php create mode 100644 tests/system/Encryption/KeyRotationDecoratorTest.php create mode 100644 user_guide_src/source/libraries/encryption/014.php diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php index 28344134aa31..c6b8aade60ca 100644 --- a/app/Config/Encryption.php +++ b/app/Config/Encryption.php @@ -23,6 +23,23 @@ class Encryption extends BaseConfig */ public string $key = ''; + /** + * -------------------------------------------------------------------------- + * Previous Encryption Keys + * -------------------------------------------------------------------------- + * + * When rotating encryption keys, add old keys here to maintain ability + * to decrypt data encrypted with previous keys. Encryption always uses + * the current $key. Decryption tries current key first, then falls back + * to previous keys if decryption fails. + * + * In .env file, use comma-separated string: + * encryption.previousKeys = hex2bin:9be8c64fcea509867...,hex2bin:3f5a1d8e9c2b7a4f6... + * + * @var list|string + */ + public array|string $previousKeys = ''; + /** * -------------------------------------------------------------------------- * Encryption Driver to Use diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index ce6594d45d36..42da45cdb52f 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -130,18 +130,39 @@ public function __construct() foreach ($properties as $property) { $this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix); - if ($this instanceof Encryption && $property === 'key') { - if (str_starts_with($this->{$property}, 'hex2bin:')) { - // Handle hex2bin prefix - $this->{$property} = hex2bin(substr($this->{$property}, 8)); - } elseif (str_starts_with($this->{$property}, 'base64:')) { - // Handle base64 prefix - $this->{$property} = base64_decode(substr($this->{$property}, 7), true); + if ($this instanceof Encryption) { + if ($property === 'key') { + $this->{$property} = $this->parseEncryptionKey($this->{$property}); + } elseif ($property === 'previousKeys') { + $keysArray = is_string($this->{$property}) ? array_map(trim(...), explode(',', $this->{$property})) : $this->{$property}; + $parsedKeys = []; + + foreach ($keysArray as $key) { + $parsedKeys[] = $this->parseEncryptionKey($key); + } + + $this->{$property} = $parsedKeys; } } } } + /** + * Parse encryption key with hex2bin: or base64: prefix + */ + protected function parseEncryptionKey(string $key): string + { + if (str_starts_with($key, 'hex2bin:')) { + return hex2bin(substr($key, 8)); + } + + if (str_starts_with($key, 'base64:')) { + return base64_decode(substr($key, 7), true); + } + + return $key; + } + /** * Initialization an environment-specific configuration setting * diff --git a/system/Encryption/Encryption.php b/system/Encryption/Encryption.php index c233f1fdfe03..17a5b4c547a2 100644 --- a/system/Encryption/Encryption.php +++ b/system/Encryption/Encryption.php @@ -138,6 +138,10 @@ public function initialize(?EncryptionConfig $config = null) $handlerName = 'CodeIgniter\\Encryption\\Handlers\\' . $this->driver . 'Handler'; $this->encrypter = new $handlerName($config); + if (($config->previousKeys ?? []) !== []) { + $this->encrypter = new KeyRotationDecorator($this->encrypter, $config->previousKeys); + } + return $this->encrypter; } diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 3df802c1b602..8e1be06d60ed 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -154,6 +154,12 @@ public function decrypt($data, #[SensitiveParameter] $params = null) // derive a secret key $encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo); - return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); + $result = \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); + + if ($result === false) { + throw EncryptionException::forAuthenticationFailed(); + } + + return $result; } } diff --git a/system/Encryption/KeyRotationDecorator.php b/system/Encryption/KeyRotationDecorator.php new file mode 100644 index 000000000000..8778b7ce800b --- /dev/null +++ b/system/Encryption/KeyRotationDecorator.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Encryption; + +use CodeIgniter\Encryption\Exceptions\EncryptionException; +use SensitiveParameter; + +/** + * Key Rotation Decorator + * + * Wraps any EncrypterInterface implementation to provide automatic + * fallback to previous encryption keys during decryption. This enables + * seamless key rotation without requiring re-encryption of existing data. + */ +class KeyRotationDecorator implements EncrypterInterface +{ + /** + * @param EncrypterInterface $innerHandler The wrapped encryption handler + * @param list $previousKeys Array of previous encryption keys + */ + public function __construct( + private readonly EncrypterInterface $innerHandler, + private readonly array $previousKeys, + ) { + } + + /** + * {@inheritDoc} + * + * Encryption always uses the inner handler's current key. + */ + public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) + { + return $this->innerHandler->encrypt($data, $params); + } + + /** + * {@inheritDoc} + * + * Attempts decryption with current key first. If that fails and no + * explicit key was provided in $params, tries each previous key. + * + * @throws EncryptionException + */ + public function decrypt($data, #[SensitiveParameter] $params = null) + { + try { + return $this->innerHandler->decrypt($data, $params); + } catch (EncryptionException $e) { + // Don't try previous keys if an explicit key was provided + if (is_string($params) || (is_array($params) && isset($params['key']))) { + throw $e; + } + + if ($this->previousKeys === []) { + throw $e; + } + + foreach ($this->previousKeys as $previousKey) { + try { + $previousParams = is_array($params) + ? array_merge($params, ['key' => $previousKey]) + : $previousKey; + + return $this->innerHandler->decrypt($data, $previousParams); + } catch (EncryptionException) { + continue; + } + } + + throw $e; + } + } + + /** + * Delegate property access to the inner handler. + * + * @return array|bool|int|string|null + */ + public function __get(string $key) + { + if (method_exists($this->innerHandler, '__get')) { + return $this->innerHandler->__get($key); + } + + return null; + } + + /** + * Delegate property existence check to inner handler. + */ + public function __isset(string $key): bool + { + if (method_exists($this->innerHandler, '__isset')) { + return $this->innerHandler->__isset($key); + } + + return false; + } +} diff --git a/tests/system/Encryption/KeyRotationDecoratorTest.php b/tests/system/Encryption/KeyRotationDecoratorTest.php new file mode 100644 index 000000000000..e94ea16bcd65 --- /dev/null +++ b/tests/system/Encryption/KeyRotationDecoratorTest.php @@ -0,0 +1,283 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Encryption; + +use CodeIgniter\Encryption\Exceptions\EncryptionException; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Encryption as EncryptionConfig; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; + +/** + * @internal + */ +#[Group('Others')] +final class KeyRotationDecoratorTest extends CIUnitTestCase +{ + private Encryption $encryption; + + protected function setUp(): void + { + $this->encryption = new Encryption(); + } + + #[RequiresPhpExtension('openssl')] + public function testEncryptionUsesCurrentKey(): void + { + $currentKey = 'current-encryption-key'; + $previousKey = 'previous-encryption-key'; + + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = $currentKey; + $params->previousKeys = [$previousKey]; + + $encrypter = $this->encryption->initialize($params); + + $message = 'This is a plain-text message.'; + $encrypted = $encrypter->encrypt($message); + + $this->assertSame($message, $encrypter->decrypt($encrypted)); + + $this->expectException(EncryptionException::class); + $encrypter->decrypt($encrypted, ['key' => $previousKey]); + } + + #[RequiresPhpExtension('openssl')] + public function testKeyRotationDecryptsOldData(): void + { + $oldKey = 'old-encryption-key'; + $newKey = 'new-encryption-key'; + + $paramsOld = new EncryptionConfig(); + $paramsOld->driver = 'OpenSSL'; + $paramsOld->key = $oldKey; + + $oldEncrypter = $this->encryption->initialize($paramsOld); + $message = 'Sensitive data encrypted with old key'; + $encrypted = $oldEncrypter->encrypt($message); + + $paramsNew = new EncryptionConfig(); + $paramsNew->driver = 'OpenSSL'; + $paramsNew->key = $newKey; + $paramsNew->previousKeys = [$oldKey]; + + $newEncrypter = $this->encryption->initialize($paramsNew); + + $this->assertSame($message, $newEncrypter->decrypt($encrypted)); + } + + #[RequiresPhpExtension('openssl')] + public function testMultiplePreviousKeysFallback(): void + { + $key1 = 'first-key-very-long'; + $key2 = 'second-key-very-long'; + $key3 = 'third-key-very-long'; + + $params1 = new EncryptionConfig(); + $params1->driver = 'OpenSSL'; + $params1->key = $key1; + $encrypter1 = $this->encryption->initialize($params1); + $message1 = 'Message encrypted with key1'; + $encrypted1 = $encrypter1->encrypt($message1); + + $params2 = new EncryptionConfig(); + $params2->driver = 'OpenSSL'; + $params2->key = $key2; + $encrypter2 = $this->encryption->initialize($params2); + $message2 = 'Message encrypted with key2'; + $encrypted2 = $encrypter2->encrypt($message2); + + $params3 = new EncryptionConfig(); + $params3->driver = 'OpenSSL'; + $params3->key = $key3; + $params3->previousKeys = [$key2, $key1]; + + $encrypter3 = $this->encryption->initialize($params3); + + $this->assertSame($message1, $encrypter3->decrypt($encrypted1)); + $this->assertSame($message2, $encrypter3->decrypt($encrypted2)); + } + + #[RequiresPhpExtension('openssl')] + public function testExplicitKeyPreventsRotation(): void + { + $currentKey = 'current-key-very-long'; + $previousKey = 'previous-key-very-long'; + $explicitKey = 'explicit-key-very-long'; + + $paramsOld = new EncryptionConfig(); + $paramsOld->driver = 'OpenSSL'; + $paramsOld->key = $previousKey; + $oldEncrypter = $this->encryption->initialize($paramsOld); + $message = 'Test message'; + $encrypted = $oldEncrypter->encrypt($message); + + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = $currentKey; + $params->previousKeys = [$previousKey]; + $encrypter = $this->encryption->initialize($params); + + $this->expectException(EncryptionException::class); + $encrypter->decrypt($encrypted, ['key' => $explicitKey]); + } + + #[RequiresPhpExtension('openssl')] + public function testEmptyPreviousKeysNoFallback(): void + { + $key1 = 'first-key-very-long'; + $key2 = 'second-key-very-long'; + + $params1 = new EncryptionConfig(); + $params1->driver = 'OpenSSL'; + $params1->key = $key1; + $encrypter1 = $this->encryption->initialize($params1); + $message = 'Test message'; + $encrypted = $encrypter1->encrypt($message); + + $params2 = new EncryptionConfig(); + $params2->driver = 'OpenSSL'; + $params2->key = $key2; + $params2->previousKeys = []; + $encrypter2 = $this->encryption->initialize($params2); + + $this->expectException(EncryptionException::class); + $encrypter2->decrypt($encrypted); + } + + #[RequiresPhpExtension('openssl')] + public function testAllKeysFailThrowsOriginalException(): void + { + $correctKey = 'correct-key-very-long'; + $wrongKey1 = 'wrong-key-1-very-long'; + $wrongKey2 = 'wrong-key-2-very-long'; + $wrongKey3 = 'wrong-key-3-very-long'; + + $paramsCorrect = new EncryptionConfig(); + $paramsCorrect->driver = 'OpenSSL'; + $paramsCorrect->key = $correctKey; + $encrypter = $this->encryption->initialize($paramsCorrect); + $message = 'Test message'; + $encrypted = $encrypter->encrypt($message); + + $paramsWrong = new EncryptionConfig(); + $paramsWrong->driver = 'OpenSSL'; + $paramsWrong->key = $wrongKey1; + $paramsWrong->previousKeys = [$wrongKey2, $wrongKey3]; + $encrypterWrong = $this->encryption->initialize($paramsWrong); + + $this->expectException(EncryptionException::class); + $this->expectExceptionMessage('authentication failed'); + $encrypterWrong->decrypt($encrypted); + } + + #[RequiresPhpExtension('openssl')] + public function testPropertyAccessDelegation(): void + { + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = 'test-key-very-long'; + $params->cipher = 'AES-128-CBC'; + $params->previousKeys = ['old-key']; + + $encrypter = $this->encryption->initialize($params); + + $this->assertSame('AES-128-CBC', $encrypter->cipher); + $this->assertSame('test-key-very-long', $encrypter->key); + } + + #[RequiresPhpExtension('sodium')] + public function testKeyRotationWithSodiumHandler(): void + { + $oldKey = sodium_crypto_secretbox_keygen(); + $newKey = sodium_crypto_secretbox_keygen(); + + $paramsOld = new EncryptionConfig(); + $paramsOld->driver = 'Sodium'; + $paramsOld->key = $oldKey; + $oldEncrypter = $this->encryption->initialize($paramsOld); + $message = 'Sensitive data encrypted with old Sodium key'; + $encrypted = $oldEncrypter->encrypt($message); + + $paramsNew = new EncryptionConfig(); + $paramsNew->driver = 'Sodium'; + $paramsNew->key = $newKey; + $paramsNew->previousKeys = [$oldKey]; + $newEncrypter = $this->encryption->initialize($paramsNew); + + $this->assertSame($message, $newEncrypter->decrypt($encrypted)); + + $newMessage = 'New message with new key'; + $newEncrypted = $newEncrypter->encrypt($newMessage); + $this->assertSame($newMessage, $newEncrypter->decrypt($newEncrypted)); + } + + #[RequiresPhpExtension('openssl')] + public function testRealisticKeyRotationScenario(): void + { + $q1Key = 'q1-2026-key-very-long'; + $q2Key = 'q2-2026-key-very-long'; + $q3Key = 'q3-2026-key-very-long'; + $q4Key = 'q4-2026-key-very-long'; + + // Q1: Encrypt user data + $configQ1 = new EncryptionConfig(); + $configQ1->driver = 'OpenSSL'; + $configQ1->key = $q1Key; + $encrypterQ1 = $this->encryption->initialize($configQ1); + $userData = 'user-sensitive-data-from-q1'; + $encryptedQ1 = $encrypterQ1->encrypt($userData); + + // Q2: Rotate to new key, keep Q1 for BC + $configQ2 = new EncryptionConfig(); + $configQ2->driver = 'OpenSSL'; + $configQ2->key = $q2Key; + $configQ2->previousKeys = [$q1Key]; + $encrypterQ2 = $this->encryption->initialize($configQ2); + + // Can still read Q1 data + $this->assertSame($userData, $encrypterQ2->decrypt($encryptedQ1)); + + // New data encrypted with Q2 key + $newData = 'user-sensitive-data-from-q2'; + $encryptedQ2 = $encrypterQ2->encrypt($newData); + $this->assertSame($newData, $encrypterQ2->decrypt($encryptedQ2)); + + // Q3: Rotate to new key, keep Q2 and Q1 for BC + $configQ3 = new EncryptionConfig(); + $configQ3->driver = 'OpenSSL'; + $configQ3->key = $q3Key; + $configQ3->previousKeys = [$q2Key, $q1Key]; + $encrypterQ3 = $this->encryption->initialize($configQ3); + + // Can still read Q1 and Q2 data + $this->assertSame($userData, $encrypterQ3->decrypt($encryptedQ1)); + $this->assertSame($newData, $encrypterQ3->decrypt($encryptedQ2)); + + // Q4: Rotate to new key, keep only Q3 and Q2 (drop Q1 - data should be re-encrypted by now) + $configQ4 = new EncryptionConfig(); + $configQ4->driver = 'OpenSSL'; + $configQ4->key = $q4Key; + $configQ4->previousKeys = [$q3Key, $q2Key]; + $encrypterQ4 = $this->encryption->initialize($configQ4); + + // Can still read Q2 and Q3 data + $this->assertSame($newData, $encrypterQ4->decrypt($encryptedQ2)); + + // But Q1 data is no longer accessible (as intended) + $this->expectException(EncryptionException::class); + $encrypterQ4->decrypt($encryptedQ1); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index f8e11e1a9608..c4dcb7d7d40f 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -241,6 +241,7 @@ Libraries - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. - **DataConverter:** Added ``EnumCast`` caster for database and entity. - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. +- **Encryption:** Added ``Config\Encryption::$previousKeys`` configuration option to support encryption key rotation. When decryption with the current key fails, the system automatically falls back to previous keys, allowing you to rotate encryption keys without losing access to old encrypted data. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index 27192550ae1d..bbdfa1e053c9 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -177,6 +177,55 @@ Similarly, you can use these prefixes in your **.env** file, too! // or encryption.key = base64: +Encryption Key Rotation +======================= + +.. versionadded:: 4.7.0 + +When you need to rotate your encryption key (for security best practices or compliance requirements), +you can use the ``previousKeys`` configuration option to maintain the ability to decrypt data encrypted +with old keys while using a new key for all new encryption operations. + +How It Works +------------ + +- **Encryption** always uses the current ``key`` value +- **Decryption** tries the current ``key`` first +- If decryption fails, it automatically falls back to trying each key in ``previousKeys`` +- This allows seamless key rotation without data loss + +Configuration +------------- + +Add your old keys to the ``$previousKeys`` property in **app/Config/Encryption.php**: + +.. literalinclude:: encryption/014.php + +Using .env File +--------------- + +You can also configure previous keys in your **.env** file (recommended) using a comma-separated list: + +:: + + encryption.key = hex2bin:your_new_key + encryption.previousKeys = hex2bin:old_key_1,hex2bin:old_key_2 + +The framework automatically parses the comma-separated string into an array and processes each key. + +Key Rotation Workflow +--------------------- + +1. **Before rotation**: Data is encrypted and decrypted with ``key`` +2. **Start rotation**: Move current ``key`` value to ``previousKeys`` array, set new value for ``key`` +3. **During rotation**: New data encrypted with new ``key``, old data still decryptable via ``previousKeys`` +4. **Re-encrypt data** (optional): Decrypt and re-encrypt existing data with the new key +5. **Complete rotation**: Once all data is re-encrypted, remove old keys from ``previousKeys`` + +.. important:: The ``previousKeys`` feature is for **decryption fallback only**. All new encryption + operations always use the current ``key``. If you pass an explicit key via the ``$params`` + argument to ``encrypt()`` or ``decrypt()``, the previousKeys fallback will not be used. + Padding ======= diff --git a/user_guide_src/source/libraries/encryption/014.php b/user_guide_src/source/libraries/encryption/014.php new file mode 100644 index 000000000000..77a2255419ab --- /dev/null +++ b/user_guide_src/source/libraries/encryption/014.php @@ -0,0 +1,17 @@ + Date: Sat, 10 Jan 2026 16:51:00 +0100 Subject: [PATCH 66/84] fix: make router cache attributes time independent (#9881) --- deptrac.yaml | 1 + system/Router/Attributes/Cache.php | 6 ++++-- tests/system/Router/Attributes/CacheTest.php | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/deptrac.yaml b/deptrac.yaml index a35887183f05..4f7d8367bae6 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -226,6 +226,7 @@ deptrac: - +Controller Router: - HTTP + - I18n Security: - Cookie - I18n diff --git a/system/Router/Attributes/Cache.php b/system/Router/Attributes/Cache.php index 5bf083f5fe4a..447f9e48c260 100644 --- a/system/Router/Attributes/Cache.php +++ b/system/Router/Attributes/Cache.php @@ -16,6 +16,7 @@ use Attribute; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\I18n\Time; /** * Cache Attribute @@ -74,7 +75,8 @@ public function before(RequestInterface $request): RequestInterface|ResponseInte foreach ($cached['headers'] as $name => $value) { $response->setHeader($name, $value); } - $response->setHeader('Age', (string) (time() - ($cached['timestamp'] ?? time()))); + $time = Time::now()->getTimestamp(); + $response->setHeader('Age', (string) ($time - ($cached['timestamp'] ?? $time))); return $response; } @@ -122,7 +124,7 @@ public function after(RequestInterface $request, ResponseInterface $response): ? 'body' => $response->getBody(), 'headers' => $headers, 'status' => $response->getStatusCode(), - 'timestamp' => time(), + 'timestamp' => Time::now()->getTimestamp(), ]; cache()->save($cacheKey, $data, $this->for); diff --git a/tests/system/Router/Attributes/CacheTest.php b/tests/system/Router/Attributes/CacheTest.php index 2eb114c9cd87..2cd155aa22de 100644 --- a/tests/system/Router/Attributes/CacheTest.php +++ b/tests/system/Router/Attributes/CacheTest.php @@ -17,6 +17,7 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\I18n\Time; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockAppConfig; use Config\Services; @@ -34,6 +35,15 @@ protected function setUp(): void // Clear cache before each test cache()->clean(); + + Time::setTestNow('2026-01-10 12:00:00'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + Time::setTestNow(); } public function testConstructorDefaults(): void @@ -73,7 +83,7 @@ public function testBeforeReturnsCachedResponseWhenFound(): void 'body' => 'Cached content', 'status' => 200, 'headers' => ['Content-Type' => 'text/html'], - 'timestamp' => time() - 10, + 'timestamp' => Time::now()->getTimestamp() - 10, ]; cache()->save($cacheKey, $cachedData, 3600); @@ -124,7 +134,7 @@ public function testBeforeUsesCustomCacheKey(): void 'body' => 'Custom cached content', 'status' => 200, 'headers' => [], - 'timestamp' => time(), + 'timestamp' => Time::now()->getTimestamp(), ]; cache()->save('my_custom_key', $cachedData, 3600); From 41a92ec5b4a332966c25e7e0016f70068fa49365 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 17 Jan 2026 11:41:17 +0500 Subject: [PATCH 67/84] feat: APCu caching driver (#9874) --- .github/workflows/test-phpunit.yml | 3 +- admin/framework/composer.json | 1 + app/Config/Cache.php | 2 + composer.json | 1 + system/Cache/Handlers/ApcuHandler.php | 130 +++++++++++++ .../system/Cache/Handlers/ApcuHandlerTest.php | 178 ++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/libraries/caching.rst | 9 +- 8 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 system/Cache/Handlers/ApcuHandler.php create mode 100644 tests/system/Cache/Handlers/ApcuHandlerTest.php diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index 38f432c4b796..d9191cec858d 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -163,7 +163,8 @@ jobs: enable-artifact-upload: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} enable-coverage: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} enable-profiling: ${{ matrix.php-version == needs.coverage-php-version.outputs.version }} - extra-extensions: redis, memcached + extra-extensions: redis, memcached, apcu + extra-ini-options: apc.enable_cli=1 extra-composer-options: ${{ matrix.composer-option }} coveralls: diff --git a/admin/framework/composer.json b/admin/framework/composer.json index 366a0a7ecc13..ab34d89fd260 100644 --- a/admin/framework/composer.json +++ b/admin/framework/composer.json @@ -27,6 +27,7 @@ "predis/predis": "^3.0" }, "suggest": { + "ext-apcu": "If you use Cache class ApcuHandler", "ext-curl": "If you use CURLRequest class", "ext-dom": "If you use TestResponse", "ext-exif": "If you run Image class tests", diff --git a/app/Config/Cache.php b/app/Config/Cache.php index a8e3e1f053ff..38ac5419d84c 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -3,6 +3,7 @@ namespace Config; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\Handlers\ApcuHandler; use CodeIgniter\Cache\Handlers\DummyHandler; use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\Cache\Handlers\MemcachedHandler; @@ -143,6 +144,7 @@ class Cache extends BaseConfig * @var array> */ public array $validHandlers = [ + 'apcu' => ApcuHandler::class, 'dummy' => DummyHandler::class, 'file' => FileHandler::class, 'memcached' => MemcachedHandler::class, diff --git a/composer.json b/composer.json index 00ae50147db8..a9502e56dd89 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "codeigniter4/framework": "self.version" }, "suggest": { + "ext-apcu": "If you use Cache class ApcuHandler", "ext-curl": "If you use CURLRequest class", "ext-dom": "If you use TestResponse", "ext-exif": "If you run Image class tests", diff --git a/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php new file mode 100644 index 000000000000..ef0f51c50dc7 --- /dev/null +++ b/system/Cache/Handlers/ApcuHandler.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use APCUIterator; +use Closure; +use CodeIgniter\I18n\Time; +use Config\Cache; + +/** + * APCu cache handler + * + * @see \CodeIgniter\Cache\Handlers\ApcuHandlerTest + */ +class ApcuHandler extends BaseHandler +{ + /** + * Note: Use `CacheFactory::getHandler()` to instantiate. + */ + public function __construct(Cache $config) + { + $this->prefix = $config->prefix; + } + + public function initialize(): void + { + } + + public function get(string $key): mixed + { + $key = static::validateKey($key, $this->prefix); + $success = false; + + $data = apcu_fetch($key, $success); + + // Success returned by reference from apcu_fetch() + return $success ? $data : null; + } + + public function save(string $key, $value, int $ttl = 60): bool + { + $key = static::validateKey($key, $this->prefix); + + return apcu_store($key, $value, $ttl); + } + + public function remember(string $key, int $ttl, Closure $callback): mixed + { + $key = static::validateKey($key, $this->prefix); + + return apcu_entry($key, $callback, $ttl); + } + + public function delete(string $key): bool + { + $key = static::validateKey($key, $this->prefix); + + return apcu_delete($key); + } + + public function deleteMatching(string $pattern): int + { + $matchedKeys = array_filter( + array_keys(iterator_to_array(new APCUIterator(null, APC_ITER_KEY))), + static fn ($key): bool => fnmatch($pattern, $key), + ); + + if ($matchedKeys !== []) { + return count($matchedKeys) - count(apcu_delete($matchedKeys)); + } + + return 0; + } + + public function increment(string $key, int $offset = 1): false|int + { + $key = static::validateKey($key, $this->prefix); + + return apcu_inc($key, $offset); + } + + public function decrement(string $key, int $offset = 1): false|int + { + $key = static::validateKey($key, $this->prefix); + + return apcu_dec($key, $offset); + } + + public function clean(): bool + { + return apcu_clear_cache(); + } + + public function getCacheInfo(): array|false + { + return apcu_cache_info(true); + } + + public function getMetaData(string $key): ?array + { + $key = static::validateKey($key, $this->prefix); + $metadata = apcu_key_info($key); + + if ($metadata !== null) { + return [ + 'expire' => $metadata['ttl'] > 0 ? Time::now()->getTimestamp() + $metadata['ttl'] : null, + 'mtime' => $metadata['mtime'], + 'data' => apcu_fetch($key), + ]; + } + + return null; + } + + public function isSupported(): bool + { + return extension_loaded('apcu') && apcu_enabled(); + } +} diff --git a/tests/system/Cache/Handlers/ApcuHandlerTest.php b/tests/system/Cache/Handlers/ApcuHandlerTest.php new file mode 100644 index 000000000000..5d5d5f7b2f6f --- /dev/null +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\CLI\CLI; +use CodeIgniter\I18n\Time; +use Config\Cache; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; + +/** + * @internal + */ +#[Group('CacheLive')] +#[RequiresPhpExtension('apcu')] +final class ApcuHandlerTest extends AbstractHandlerTestCase +{ + /** + * @return list + */ + private static function getKeyArray(): array + { + return [ + self::$key1, + self::$key2, + self::$key3, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->handler = CacheFactory::getHandler(new Cache(), 'apcu'); + } + + protected function tearDown(): void + { + foreach (self::getKeyArray() as $key) { + $this->handler->delete($key); + } + } + + public function testNew(): void + { + $this->assertInstanceOf(ApcuHandler::class, $this->handler); + } + + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testGet(): void + { + $this->handler->save(self::$key1, 'value', 2); + + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + /** + * This test waits for 3 seconds before last assertion so this + * is naturally a "slow" test on the perspective of the default limit. + * + * @timeLimit 3.5 + */ + public function testRemember(): void + { + $this->handler->remember(self::$key1, 2, static fn (): string => 'value'); + + $this->assertSame('value', $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testSave(): void + { + $this->assertTrue($this->handler->save(self::$key1, 'value')); + } + + public function testSavePermanent(): void + { + $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); + $metaData = $this->handler->getMetaData(self::$key1); + + $this->assertNull($metaData['expire']); + $this->assertLessThanOrEqual(1, $metaData['mtime'] - Time::now()->getTimestamp()); + $this->assertSame('value', $metaData['data']); + + $this->assertTrue($this->handler->delete(self::$key1)); + } + + public function testDelete(): void + { + $this->handler->save(self::$key1, 'value'); + + $this->assertTrue($this->handler->delete(self::$key1)); + $this->assertFalse($this->handler->delete(self::$dummy)); + } + + public function testDeleteMatching(): void + { + // Save items to match on + for ($i = 1; $i <= 50; $i++) { + $this->handler->save('key_' . $i, 'value' . $i); + } + + // Checking that with pattern 'key_1*' only 11 entries deleted: + // key_1, key_10, key_11, key_12, key_13, key_14, key_15, key_16, key_17, key_18, key_19 + $this->assertSame(11, $this->handler->deleteMatching('key_1*')); + + // Checking that with pattern '*1', only 3 entries deleted: + // key_21, key_31, key_41 + $this->assertSame(3, $this->handler->deleteMatching('key_*1')); + + // Checking that with pattern '*5*' only 5 entries deleted: + // key_5, key_25, key_35, key_45, key_50 + $this->assertSame(5, $this->handler->deleteMatching('*5*')); + + // Check final number of cache entries + $this->assertSame(31, $this->handler->getCacheInfo()['num_entries']); + } + + public function testIncrementAndDecrement(): void + { + $this->handler->save('counter', 100); + + foreach (range(1, 10) as $step) { + $this->handler->increment('counter', $step); + } + + $this->assertSame(155, $this->handler->get('counter')); + + $this->handler->decrement('counter', 20); + $this->assertSame(135, $this->handler->get('counter')); + + $this->handler->increment('counter', 5); + $this->assertSame(140, $this->handler->get('counter')); + } + + public function testClean(): void + { + $this->handler->save(self::$key1, 1); + + $this->assertTrue($this->handler->clean()); + } + + public function testGetCacheInfo(): void + { + $this->handler->save(self::$key1, 'value'); + + $this->assertIsArray($this->handler->getCacheInfo()); + } + + public function testIsSupported(): void + { + $this->assertTrue($this->handler->isSupported()); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index c4dcb7d7d40f..30cb12744608 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -236,6 +236,7 @@ Libraries - **Cache:** Added ``persistent`` config item to Redis handler. - **Cache:** Added support for HTTP status in ``ResponseCache``. - **Cache:** Added ``Config\Cache::$cacheStatusCodes`` to control which HTTP status codes are allowed to be cached by the ``PageCache`` filter. Defaults to ``[]`` (all status codes for backward compatibility). Recommended value: ``[200]`` to only cache successful responses. See :ref:`Setting $cacheStatusCodes ` for details. +- **Cache:** Added `APCu `_ caching driver. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. diff --git a/user_guide_src/source/libraries/caching.rst b/user_guide_src/source/libraries/caching.rst index f4adc04a93df..b208d63499df 100644 --- a/user_guide_src/source/libraries/caching.rst +++ b/user_guide_src/source/libraries/caching.rst @@ -36,7 +36,7 @@ $handler ======== The is the name of the handler that should be used as the primary handler when starting up the engine. -Available names are: dummy, file, memcached, redis, predis, wincache. +Available names are: apcu, dummy, file, memcached, redis, predis, wincache. $backupHandler ============== @@ -273,6 +273,13 @@ Class Reference Drivers ******* +APCu Caching +============ + +APCu is an in-memory key-value store for PHP. + +To use it, you need the `APCu PHP extension `_. + File-based Caching ================== From 1b56deb6a90b56a8a624313099d14b0e7e51aada Mon Sep 17 00:00:00 2001 From: neznaika0 Date: Sat, 17 Jan 2026 09:44:01 +0300 Subject: [PATCH 68/84] refactor: Rework `Entity` class (#9878) --- system/Entity/Cast/DatetimeCast.php | 4 +- system/Entity/Entity.php | 115 +++++++++------- tests/system/Entity/EntityTest.php | 127 +++++++++++++++++- .../Models/ValidationModelRuleGroupTest.php | 6 +- tests/system/Models/ValidationModelTest.php | 6 +- user_guide_src/source/changelogs/v4.7.0.rst | 17 ++- user_guide_src/source/models/entities.rst | 2 - utils/phpstan-baseline/empty.notAllowed.neon | 5 - .../missingType.iterableValue.neon | 50 ------- 9 files changed, 213 insertions(+), 119 deletions(-) diff --git a/system/Entity/Cast/DatetimeCast.php b/system/Entity/Cast/DatetimeCast.php index 72206ca883f7..e49df121987e 100644 --- a/system/Entity/Cast/DatetimeCast.php +++ b/system/Entity/Cast/DatetimeCast.php @@ -14,7 +14,7 @@ namespace CodeIgniter\Entity\Cast; use CodeIgniter\I18n\Time; -use DateTime; +use DateTimeInterface; use Exception; class DatetimeCast extends BaseCast @@ -32,7 +32,7 @@ public static function get($value, array $params = []) return $value; } - if ($value instanceof DateTime) { + if ($value instanceof DateTimeInterface) { return Time::createFromInstance($value); } diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 745b4cbe3dae..6541d985d33a 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -30,7 +30,6 @@ use CodeIgniter\Entity\Cast\URICast; use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\I18n\Time; -use DateTime; use DateTimeInterface; use Exception; use JsonSerializable; @@ -79,14 +78,14 @@ class Entity implements JsonSerializable protected $casts = []; /** - * Custom convert handlers + * Custom convert handlers. * * @var array */ protected $castHandlers = []; /** - * Default convert handlers + * Default convert handlers. * * @var array */ @@ -128,29 +127,26 @@ class Entity implements JsonSerializable /** * The data caster. */ - protected DataCaster $dataCaster; + protected ?DataCaster $dataCaster = null; /** - * Holds info whenever properties have to be casted + * Holds info whenever properties have to be casted. */ private bool $_cast = true; /** - * Indicates whether all attributes are scalars (for optimization) + * Indicates whether all attributes are scalars (for optimization). */ private bool $_onlyScalars = true; /** * Allows filling in Entity parameters during construction. + * + * @param array $data */ public function __construct(?array $data = null) { - $this->dataCaster = new DataCaster( - array_merge($this->defaultCastHandlers, $this->castHandlers), - null, - null, - false, - ); + $this->dataCaster = $this->dataCaster(); $this->syncOriginal(); @@ -162,7 +158,7 @@ public function __construct(?array $data = null) * properties, using any `setCamelCasedProperty()` methods * that may or may not exist. * - * @param array $data + * @param array|bool|float|int|object|string|null> $data * * @return $this */ @@ -184,13 +180,16 @@ public function fill(?array $data = null) * of this entity as an array. All values are accessed through the * __get() magic method so will have any casts, etc applied to them. * - * @param bool $onlyChanged If true, only return values that have changed since object creation + * @param bool $onlyChanged If true, only return values that have changed since object creation. * @param bool $cast If true, properties will be cast. * @param bool $recursive If true, inner entities will be cast as array as well. + * + * @return array */ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recursive = false): array { - $this->_cast = $cast; + $originalCast = $this->_cast; + $this->_cast = $cast; $keys = array_filter(array_keys($this->attributes), static fn ($key): bool => ! str_starts_with($key, '_')); @@ -219,7 +218,7 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu } } - $this->_cast = true; + $this->_cast = $originalCast; return $return; } @@ -227,8 +226,10 @@ public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recu /** * Returns the raw values of the current attributes. * - * @param bool $onlyChanged If true, only return values that have changed since object creation + * @param bool $onlyChanged If true, only return values that have changed since object creation. * @param bool $recursive If true, inner entities will be cast as array as well. + * + * @return array */ public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array { @@ -370,8 +371,6 @@ public function syncOriginal() * Checks a property to see if it has changed since the entity * was created. Or, without a parameter, checks if any * properties have changed. - * - * @param string|null $key class property */ public function hasChanged(?string $key = null): bool { @@ -500,7 +499,9 @@ private function normalizeValue(mixed $data): mixed } /** - * Set raw data array without any mutations + * Set raw data array without any mutations. + * + * @param array $data * * @return $this */ @@ -513,23 +514,11 @@ public function injectRawData(array $data) return $this; } - /** - * Set raw data array without any mutations - * - * @return $this - * - * @deprecated Use injectRawData() instead. - */ - public function setAttributes(array $data) - { - return $this->injectRawData($data); - } - /** * Checks the datamap to see if this property name is being mapped, - * and returns the db column name, if any, or the original property name. + * and returns the DB column name, if any, or the original property name. * - * @return string db column name + * @return string Database column name. */ protected function mapProperty(string $key) { @@ -537,7 +526,7 @@ protected function mapProperty(string $key) return $key; } - if (! empty($this->datamap[$key])) { + if (array_key_exists($key, $this->datamap) && $this->datamap[$key] !== '') { return $this->datamap[$key]; } @@ -545,10 +534,10 @@ protected function mapProperty(string $key) } /** - * Converts the given string|timestamp|DateTime|Time instance + * Converts the given string|timestamp|DateTimeInterface instance * into the "CodeIgniter\I18n\Time" object. * - * @param DateTime|float|int|string|Time $value + * @param DateTimeInterface|float|int|string $value * * @return Time * @@ -568,22 +557,50 @@ protected function mutateDate($value) * @param string $attribute Attribute name * @param string $method Allowed to "get" and "set" * - * @return array|bool|float|int|object|string|null + * @return array|bool|float|int|object|string|null * * @throws CastException */ protected function castAs($value, string $attribute, string $method = 'get') { - return $this->dataCaster - // @TODO if $casts is readonly, we don't need the setTypes() method. - ->setTypes($this->casts) - ->castAs($value, $attribute, $method); + if ($this->dataCaster() instanceof DataCaster) { + return $this->dataCaster + // @TODO if $casts is readonly, we don't need the setTypes() method. + ->setTypes($this->casts) + ->castAs($value, $attribute, $method); + } + + return $value; + } + + /** + * Returns a DataCaster instance when casts are defined. + * If no casts are configured, no DataCaster is created and null is returned. + */ + protected function dataCaster(): ?DataCaster + { + if ($this->casts === []) { + $this->dataCaster = null; + + return null; + } + + if (! $this->dataCaster instanceof DataCaster) { + $this->dataCaster = new DataCaster( + array_merge($this->defaultCastHandlers, $this->castHandlers), + null, + null, + false, + ); + } + + return $this->dataCaster; } /** - * Support for json_encode() + * Support for json_encode(). * - * @return array + * @return array */ #[ReturnTypeWillChange] public function jsonSerialize() @@ -592,7 +609,7 @@ public function jsonSerialize() } /** - * Change the value of the private $_cast property + * Change the value of the private $_cast property. * * @return bool|Entity */ @@ -616,7 +633,7 @@ public function cast(?bool $cast = null) * $this->my_property = $p; * $this->setMyProperty() = $p; * - * @param array|bool|float|int|object|string|null $value + * @param array|bool|float|int|object|string|null $value * * @return void * @@ -646,7 +663,7 @@ public function __set(string $key, $value = null) } // If a "`set` + $key" method exists, it is also a setter. - if (method_exists($this, $method) && $method !== 'setAttributes') { + if (method_exists($this, $method)) { $this->{$method}($value); return; @@ -667,11 +684,9 @@ public function __set(string $key, $value = null) * $p = $this->my_property * $p = $this->getMyProperty() * - * @return array|bool|float|int|object|string|null + * @return array|bool|float|int|object|string|null * * @throws Exception - * - * @params string $key class property */ public function __get(string $key) { diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 42135c816ed9..7c19d9d09b89 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -16,6 +16,7 @@ use ArrayIterator; use ArrayObject; use Closure; +use CodeIgniter\DataCaster\DataCaster; use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\HTTP\URI; use CodeIgniter\I18n\Time; @@ -75,6 +76,32 @@ public function testSetArrayToPropertyNamedAttributes(): void $this->assertSame($expected, $entity->toRawArray()); } + public function testSetGetAttributesMethod(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'foo' => null, + 'attributes' => null, + ]; + + public function setAttributes(string $value): self + { + $this->attributes['attributes'] = $value; + + return $this; + } + + public function getAttributes(): string + { + return $this->attributes['attributes']; + } + }; + + $entity->setAttributes('attributes'); + + $this->assertSame('attributes', $entity->getAttributes()); + } + public function testSimpleSetAndGet(): void { $entity = $this->getEntity(); @@ -358,11 +385,11 @@ public function testCastIntBool(): void ]; }; - $entity->setAttributes(['active' => '1']); + $entity->injectRawData(['active' => '1']); $this->assertTrue($entity->active); - $entity->setAttributes(['active' => '0']); + $entity->injectRawData(['active' => '0']); $this->assertFalse($entity->active); @@ -1078,6 +1105,38 @@ public function testAsArraySwapped(): void ], $result); } + public function testAsArrayRestoringCastStatus(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'first' => null, + ]; + protected $original = [ + 'first' => null, + ]; + protected $casts = [ + 'first' => 'integer', + ]; + }; + $entity->first = '2026 Year'; + + // Disabled casting properties, but we will allow casting in the method. + $entity->cast(false); + $beforeCast = $entity->cast(); + $result = $entity->toArray(true, true); + + $this->assertSame(2026, $result['first']); + $this->assertSame($beforeCast, $entity->cast()); + + // Enabled casting properties, but we will disallow casting in the method. + $entity->cast(true); + $beforeCast = $entity->cast(); + $result = $entity->toArray(true, false); + + $this->assertSame('2026 Year', $result['first']); + $this->assertSame($beforeCast, $entity->cast()); + } + public function testDataMappingIssetSwapped(): void { $entity = $this->getSimpleSwappedEntity(); @@ -1427,6 +1486,70 @@ public function testJsonSerializableEntity(): void $this->assertSame(json_encode($entity->toArray()), json_encode($entity)); } + public function testDataCasterInit(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'first' => '12345', + ]; + protected $casts = [ + 'first' => 'integer', + ]; + }; + + $getDataCaster = $this->getPrivateMethodInvoker($entity, 'dataCaster'); + + $this->assertInstanceOf(DataCaster::class, $getDataCaster()); + $this->assertInstanceOf(DataCaster::class, $this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame(12345, $entity->first); + + // Disable casting, but the DataCaster is initialized + $entity->cast(false); + $this->assertInstanceOf(DataCaster::class, $getDataCaster()); + $this->assertInstanceOf(DataCaster::class, $this->getPrivateProperty($entity, 'dataCaster')); + $this->assertIsString($entity->first); + + // Method castAs() ignore on the $_cast option + $this->assertSame(12345, $this->getPrivateMethodInvoker($entity, 'castAs')('12345', 'first')); + + // Restore casting + $entity->cast(true); + $this->assertInstanceOf(DataCaster::class, $getDataCaster()); + $this->assertInstanceOf(DataCaster::class, $this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame(12345, $entity->first); + } + + public function testDataCasterInitEmptyCasts(): void + { + $entity = new class () extends Entity { + protected $attributes = [ + 'first' => '12345', + ]; + protected $casts = []; + }; + + $getDataCaster = $this->getPrivateMethodInvoker($entity, 'dataCaster'); + + $this->assertNull($getDataCaster()); + $this->assertNull($this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame('12345', $entity->first); + + // Disable casting, the DataCaster was not initialized + $entity->cast(false); + $this->assertNull($getDataCaster()); + $this->assertNull($this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame('12345', $entity->first); + + // Method castAs() depends on the $_cast option + $this->assertSame('12345', $this->getPrivateMethodInvoker($entity, 'castAs')('12345', 'first')); + + // Restore casting + $entity->cast(true); + $this->assertNull($getDataCaster()); + $this->assertNull($this->getPrivateProperty($entity, 'dataCaster')); + $this->assertSame('12345', $entity->first); + } + private function getEntity(): object { return new class () extends Entity { diff --git a/tests/system/Models/ValidationModelRuleGroupTest.php b/tests/system/Models/ValidationModelRuleGroupTest.php index 37e0ae4e3e6e..8059e1fee7d2 100644 --- a/tests/system/Models/ValidationModelRuleGroupTest.php +++ b/tests/system/Models/ValidationModelRuleGroupTest.php @@ -380,7 +380,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesTrueAndCallingCl // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -421,7 +421,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesFalse(): void // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -457,7 +457,7 @@ public function testInsertEntityValidateEntireRules(): void }; $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'field1' => 'value1', // field2 is missing 'field3' => '', diff --git a/tests/system/Models/ValidationModelTest.php b/tests/system/Models/ValidationModelTest.php index 7ec08c9e36ac..744238076e13 100644 --- a/tests/system/Models/ValidationModelTest.php +++ b/tests/system/Models/ValidationModelTest.php @@ -393,7 +393,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesTrueAndCallingCl // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -434,7 +434,7 @@ public function testUpdateEntityWithPropertyCleanValidationRulesFalse(): void // Simulate to get the entity from the database. $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'id' => '1', 'field1' => 'value1', 'field2' => 'value2', @@ -470,7 +470,7 @@ public function testInsertEntityValidateEntireRules(): void }; $entity = new SimpleEntity(); - $entity->setAttributes([ + $entity->injectRawData([ 'field1' => 'value1', // field2 is missing 'field3' => '', diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 30cb12744608..d907b3caac45 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -111,6 +111,14 @@ parameter is ``true``. Previously, properties containing arrays were not recursi If you were relying on the old behavior where arrays remained unconverted, you will need to update your code. +Entity and DataCaster +--------------------- + +Previously, the ``DataCaster`` object was always initialized, even when type casting was not configured (an empty ``$casts = []`` array). + +``DataCaster`` is now created on demand and will be ``null`` when type casting is not configured. +While this change should not affect typical usage, developers should be aware that ``$dataCaster`` may now be nullable in some cases. + Encryption Handlers ------------------- @@ -200,12 +208,18 @@ Method Signature Changes - ``prepare(): void`` - ``respond(): void`` +Property Signature Changes +========================== + +- **Entity:** The protected property ``CodeIgniter\Entity\Entity::$dataCaster`` type has been changed from ``DataCaster`` to ``?DataCaster`` (nullable). + Removed Deprecated Items ======================== - **BaseModel:** The deprecated method ``transformDataRowToArray()`` has been removed. - **Cache:** The deprecated return type ``false`` for ``CodeIgniter\Cache\CacheInterface::getMetaData()`` has been replaced with ``null`` type. - **CodeIgniter:** The deprecated ``CodeIgniter\CodeIgniter::resolvePlatformExtensions()`` has been removed. +- **Entity:** The deprecated ``CodeIgniter\Entity\Entity::setAttributes()`` has been removed. Use ``CodeIgniter\Entity\Entity::injectRawData()`` instead. - **IncomingRequest:** The deprecated methods has been removed: - ``CodeIgniter\HTTP\IncomingRequest\detectURI()`` - ``CodeIgniter\HTTP\IncomingRequest\detectPath()`` @@ -251,7 +265,6 @@ Libraries - **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. - **Toolbar:** Fixed an issue where the Debug Toolbar was incorrectly injected into responses generated by third-party libraries (e.g., Dompdf) that use native PHP headers instead of the framework's Response object. - Commands ======== @@ -307,7 +320,6 @@ Changes - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. - **Toolbar:** Added ``$disableOnHeaders`` property to **app/Config/Toolbar.php**. - ************ Deprecations ************ @@ -324,6 +336,7 @@ Bugs Fixed - **Cookie:** The ``CookieInterface::SAMESITE_STRICT``, ``CookieInterface::SAMESITE_LAX``, and ``CookieInterface::SAMESITE_NONE`` constants are now written in ucfirst style to be consistent with usage in the rest of the framework. - **Cache:** Changed ``WincacheHandler::increment()`` and ``WincacheHandler::decrement()`` to return ``bool`` instead of ``mixed``. +- **Entity:** Calling ``CodeIgniter\Entity\Entity::toArray()`` always changed the value of ``$_cast`` to ``true``, instead of restoring the initial value. - **Toolbar:** Fixed **Maximum call stack size exceeded** crash when AJAX-like requests (HTMX, Turbo, Unpoly, etc.) were made on pages with Debug Toolbar enabled. See the repo's diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 62daf63e4f0c..56c17114138e 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -40,8 +40,6 @@ Assume you have a database table named ``users`` that has the following schema:: password - string created_at - datetime -.. important:: ``attributes`` is a reserved word for internal use. Prior to v4.4.0, if you use it as a column name, the Entity does not work correctly. - Create the Entity Class ======================= diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index c198e3caaf9e..1a9cdfdb2776 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -197,11 +197,6 @@ parameters: count: 2 path: ../../system/Encryption/Handlers/SodiumHandler.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' count: 1 diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index c4bccbba1584..f1cf09f775a0 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -2322,56 +2322,6 @@ parameters: count: 1 path: ../../system/Entity/Cast/URICast.php - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:__construct\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:__set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:castAs\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:fill\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:injectRawData\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:jsonSerialize\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:setAttributes\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:toArray\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - - - message: '#^Method CodeIgniter\\Entity\\Entity\:\:toRawArray\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Entity.php - - message: '#^Method CodeIgniter\\Exceptions\\PageNotFoundException\:\:lang\(\) has parameter \$args with no value type specified in iterable type array\.$#' count: 1 From 8561f539766f5834b673f4365c03f587f5f93ae4 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 18 Jan 2026 17:20:01 +0800 Subject: [PATCH 69/84] chore: rerun `phpstan:baseline` for accurate errors count (#9890) --- utils/phpstan-baseline/empty.notAllowed.neon | 2 +- utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/missingType.iterableValue.neon | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index 1a9cdfdb2776..6ade21fdf3ec 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 222 errors +# total 221 errors parameters: ignoreErrors: diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 90c6310f833e..9e11a81c645c 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2179 errors +# total 2168 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index f1cf09f775a0..66a3b4d712ac 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1315 errors +# total 1305 errors parameters: ignoreErrors: From 6cd74bf7cd8c7aae61ec66d31f2eaa7d04aaa3d0 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 18 Jan 2026 17:38:21 +0800 Subject: [PATCH 70/84] refactor: compare `$db->connID` to `false` (#9891) --- system/Database/Database.php | 4 ++-- utils/phpstan-baseline/booleanNot.exprNotBoolean.neon | 8 -------- utils/phpstan-baseline/loader.neon | 3 +-- 3 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 utils/phpstan-baseline/booleanNot.exprNotBoolean.neon diff --git a/system/Database/Database.php b/system/Database/Database.php index 75c959e9a46a..9598242f01bb 100644 --- a/system/Database/Database.php +++ b/system/Database/Database.php @@ -70,7 +70,7 @@ public function load(array $params = [], string $alias = '') */ public function loadForge(ConnectionInterface $db): Forge { - if (! $db->connID) { + if ($db->connID === false) { $db->initialize(); } @@ -84,7 +84,7 @@ public function loadForge(ConnectionInterface $db): Forge */ public function loadUtils(ConnectionInterface $db): BaseUtils { - if (! $db->connID) { + if ($db->connID === false) { $db->initialize(); } diff --git a/utils/phpstan-baseline/booleanNot.exprNotBoolean.neon b/utils/phpstan-baseline/booleanNot.exprNotBoolean.neon deleted file mode 100644 index 1a6537ef488d..000000000000 --- a/utils/phpstan-baseline/booleanNot.exprNotBoolean.neon +++ /dev/null @@ -1,8 +0,0 @@ -# total 2 errors - -parameters: - ignoreErrors: - - - message: '#^Only booleans are allowed in a negated boolean, mixed given\.$#' - count: 2 - path: ../../system/Database/Database.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 9e11a81c645c..b8c387c27d31 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,10 +1,9 @@ -# total 2168 errors +# total 2166 errors includes: - argument.type.neon - arguments.count.neon - assign.propertyType.neon - - booleanNot.exprNotBoolean.neon - codeigniter.getReassignArray.neon - codeigniter.modelArgumentType.neon - codeigniter.superglobalAccess.neon From 468550745568381fa093361308f68cc77997b5de Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 18 Jan 2026 22:07:22 +0800 Subject: [PATCH 71/84] refactor: complete `QueryInterface` (#9892) --- system/Database/Query.php | 63 ++------------------- system/Database/QueryInterface.php | 29 ++++------ user_guide_src/source/changelogs/v4.7.0.rst | 13 ++++- utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/method.notFound.neon | 7 +-- 5 files changed, 27 insertions(+), 87 deletions(-) diff --git a/system/Database/Query.php b/system/Database/Query.php index c74ba6a687ab..8040f54b666c 100644 --- a/system/Database/Query.php +++ b/system/Database/Query.php @@ -100,14 +100,7 @@ public function __construct(ConnectionInterface $db) $this->db = $db; } - /** - * Sets the raw query string to use for this statement. - * - * @param mixed $binds - * - * @return $this - */ - public function setQuery(string $sql, $binds = null, bool $setEscape = true) + public function setQuery(string $sql, mixed $binds = null, bool $setEscape = true): self { $this->originalQueryString = $sql; unset($this->swappedQueryString); @@ -153,10 +146,6 @@ public function setBinds(array $binds, bool $setEscape = true) return $this; } - /** - * Returns the final, processed query string after binding, etal - * has been performed. - */ public function getQuery(): string { if (empty($this->finalQueryString)) { @@ -166,14 +155,7 @@ public function getQuery(): string return $this->finalQueryString; } - /** - * Records the execution time of the statement using microtime(true) - * for it's start and end values. If no end value is present, will - * use the current time to determine total duration. - * - * @return $this - */ - public function setDuration(float $start, ?float $end = null) + public function setDuration(float $start, ?float $end = null): self { $this->startTime = $start; @@ -200,23 +182,12 @@ public function getStartTime(bool $returnRaw = false, int $decimals = 6) return number_format($this->startTime, $decimals); } - /** - * Returns the duration of this query during execution, or null if - * the query has not been executed yet. - * - * @param int $decimals The accuracy of the returned time. - */ public function getDuration(int $decimals = 6): string { return number_format(($this->endTime - $this->startTime), $decimals); } - /** - * Stores the error description that happened for this query. - * - * @return $this - */ - public function setError(int $code, string $error) + public function setError(int $code, string $error): self { $this->errorCode = $code; $this->errorString = $error; @@ -224,44 +195,27 @@ public function setError(int $code, string $error) return $this; } - /** - * Reports whether this statement created an error not. - */ public function hasError(): bool { return ! empty($this->errorString); } - /** - * Returns the error code created while executing this statement. - */ public function getErrorCode(): int { return $this->errorCode; } - /** - * Returns the error message created while executing this statement. - */ public function getErrorMessage(): string { return $this->errorString; } - /** - * Determines if the statement is a write-type query or not. - */ public function isWriteType(): bool { return $this->db->isWriteType($this->originalQueryString); } - /** - * Swaps out one table prefix for a new one. - * - * @return $this - */ - public function swapPrefix(string $orig, string $swap) + public function swapPrefix(string $orig, string $swap): self { $sql = $this->swappedQueryString ?? $this->originalQueryString; @@ -275,9 +229,6 @@ public function swapPrefix(string $orig, string $swap) return $this; } - /** - * Returns the original SQL that was passed into the system. - */ public function getOriginalQuery(): string { return $this->originalQueryString; @@ -315,9 +266,6 @@ protected function compileBinds() } } - /** - * Match bindings - */ protected function matchNamedBinds(string $sql, array $binds): string { $replacers = []; @@ -339,9 +287,6 @@ protected function matchNamedBinds(string $sql, array $binds): string return strtr($sql, $replacers); } - /** - * Match bindings - */ protected function matchSimpleBinds(string $sql, array $binds, int $bindCount, int $ml): string { if ($c = preg_match_all("/'[^']*'/", $sql, $matches) >= 1) { diff --git a/system/Database/QueryInterface.php b/system/Database/QueryInterface.php index 86eb01f4e169..841ff2cae871 100644 --- a/system/Database/QueryInterface.php +++ b/system/Database/QueryInterface.php @@ -14,8 +14,6 @@ namespace CodeIgniter\Database; /** - * Interface QueryInterface - * * Represents a single statement that can be executed against the database. * Statements are platform-specific and can handle binding of binds. */ @@ -23,29 +21,21 @@ interface QueryInterface { /** * Sets the raw query string to use for this statement. - * - * @param mixed $binds - * - * @return $this */ - public function setQuery(string $sql, $binds = null, bool $setEscape = true); + public function setQuery(string $sql, mixed $binds = null, bool $setEscape = true): self; /** * Returns the final, processed query string after binding, etal * has been performed. - * - * @return string */ - public function getQuery(); + public function getQuery(): string; /** * Records the execution time of the statement using microtime(true) * for it's start and end values. If no end value is present, will * use the current time to determine total duration. - * - * @return $this */ - public function setDuration(float $start, ?float $end = null); + public function setDuration(float $start, ?float $end = null): self; /** * Returns the duration of this query during execution, or null if @@ -57,10 +47,8 @@ public function getDuration(int $decimals = 6): string; /** * Stores the error description that happened for this query. - * - * @return $this */ - public function setError(int $code, string $error); + public function setError(int $code, string $error): self; /** * Reports whether this statement created an error not. @@ -84,8 +72,11 @@ public function isWriteType(): bool; /** * Swaps out one table prefix for a new one. - * - * @return $this */ - public function swapPrefix(string $orig, string $swap); + public function swapPrefix(string $orig, string $swap): self; + + /** + * Returns the original SQL that was passed into the system. + */ + public function getOriginalQuery(): string; } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index d907b3caac45..e8124847efc2 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -177,8 +177,11 @@ To use a different encryption key permanently, pass a custom config when creatin Interface Changes ================= -- **Cache:** The ``CacheInterface`` now includes the ``deleteMatching()`` method. If you've implemented your own caching driver from scratch, you will need to provide an implementation for this method to ensure compatibility. -- **Images:** The ``ImageHandlerInterface`` now includes a new method: ``clearMetadata()``. If you've implemented your own handler from scratch, you will need to provide an implementation for this method to ensure compatibility. +**NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods to ensure compatibility. + +- **Cache:** The ``CacheInterface`` now includes the ``deleteMatching()`` method. +- **Database:** The ``QueryInterface`` now includes the ``getOriginalQuery()`` method. +- **Images:** The ``ImageHandlerInterface`` now includes a new method: ``clearMetadata()``. Method Signature Changes ======================== @@ -204,6 +207,12 @@ Method Signature Changes - ``clean()`` - ``getCacheInfo()`` - ``getMetaData()`` +- Added native parameter and return types to ``CodeIgniter\Database\QueryInterface`` methods: + - ``setQuery(string $sql, mixed $binds = null, bool $setEscape = true): self`` + - ``getQuery(): string`` + - ``setDuration(float $start, ?float $end = null): self`` + - ``setError(int $code, string $error): self`` + - ``swapPrefix(string $orig, string $swap): self`` - Added native return types to ``CodeIgniter\Debug\Toolbar`` methods: - ``prepare(): void`` - ``respond(): void`` diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index b8c387c27d31..12a532cbb4fb 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2166 errors +# total 2165 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/method.notFound.neon b/utils/phpstan-baseline/method.notFound.neon index 1018f0fc8119..34144944c976 100644 --- a/utils/phpstan-baseline/method.notFound.neon +++ b/utils/phpstan-baseline/method.notFound.neon @@ -1,12 +1,7 @@ -# total 81 errors +# total 80 errors parameters: ignoreErrors: - - - message: '#^Call to an undefined method CodeIgniter\\Database\\QueryInterface\:\:getOriginalQuery\(\)\.$#' - count: 1 - path: ../../system/Database/BaseConnection.php - - message: '#^Call to an undefined method CodeIgniter\\View\\RendererInterface\:\:getData\(\)\.$#' count: 1 From 21fbcf6de45a3d75b7721240272022c4f82afab5 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 19 Jan 2026 18:17:14 +0330 Subject: [PATCH 72/84] feat: add `remember()` to `CacheInterface` (#9875) Co-authored-by: John Paul E. Balandan, CPA --- system/Cache/CacheInterface.php | 12 ++++++++++++ system/Cache/Handlers/BaseHandler.php | 7 ------- user_guide_src/source/changelogs/v4.7.0.rst | 1 + 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php index 9ae83f29283d..4cedb67e9538 100644 --- a/system/Cache/CacheInterface.php +++ b/system/Cache/CacheInterface.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Cache; +use Closure; + interface CacheInterface { /** @@ -38,6 +40,16 @@ public function get(string $key): mixed; */ public function save(string $key, mixed $value, int $ttl = 60): bool; + /** + * Attempts to get an item from the cache, or executes the callback + * and stores the result on cache miss. + * + * @param string $key Cache item name + * @param int $ttl Time To Live, in seconds + * @param Closure(): mixed $callback Callback executed on cache miss + */ + public function remember(string $key, int $ttl, Closure $callback): mixed; + /** * Deletes a specific item from the cache store. * diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index 2d9aeff489d1..4f7022d3c249 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -75,13 +75,6 @@ public static function validateKey($key, $prefix = ''): string return strlen($prefix . $key) > static::MAX_KEY_LENGTH ? $prefix . md5($key) : $prefix . $key; } - /** - * 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(): mixed $callback Callback return value - */ public function remember(string $key, int $ttl, Closure $callback): mixed { $value = $this->get($key); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index e8124847efc2..2fb147b2d1b8 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -180,6 +180,7 @@ Interface Changes **NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods to ensure compatibility. - **Cache:** The ``CacheInterface`` now includes the ``deleteMatching()`` method. +- **Cache:** The ``CacheInterface`` now includes the ``remember()`` method. All built-in cache handlers inherit this method via ``BaseHandler``, so no changes are required for them. - **Database:** The ``QueryInterface`` now includes the ``getOriginalQuery()`` method. - **Images:** The ``ImageHandlerInterface`` now includes a new method: ``clearMetadata()``. From b00e8b9574feda5be9fa1766bbcef2ea85c1682a Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:08:50 +0700 Subject: [PATCH 73/84] feat: add ``persistent`` config item to Session's Redis handler (#9793) * feat: added persistent config item to redis handler Session * tests: persistent connection session redis fix: test persistent parameter false * refactor: using filter_var * refactor: name test * Update system/Session/Handlers/RedisHandler.php Co-authored-by: John Paul E. Balandan, CPA * Fix filter_var on persistent * Fix phpstan error --------- Co-authored-by: John Paul E. Balandan, CPA --- system/Session/Handlers/RedisHandler.php | 32 ++--- .../Handlers/Database/RedisHandlerTest.php | 132 ++++++++++++------ user_guide_src/source/changelogs/v4.7.0.rst | 1 + 3 files changed, 109 insertions(+), 56 deletions(-) diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 27390174fb50..213272bb87c7 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -142,17 +142,19 @@ protected function setSavePath(): void } } - $password = $query['auth'] ?? null; - $database = isset($query['database']) ? (int) $query['database'] : 0; - $timeout = isset($query['timeout']) ? (float) $query['timeout'] : 0.0; - $prefix = $query['prefix'] ?? null; + $persistent = isset($query['persistent']) ? filter_var($query['persistent'], FILTER_VALIDATE_BOOL) : null; + $password = $query['auth'] ?? null; + $database = isset($query['database']) ? (int) $query['database'] : 0; + $timeout = isset($query['timeout']) ? (float) $query['timeout'] : 0.0; + $prefix = $query['prefix'] ?? null; $this->savePath = [ - 'host' => $host, - 'port' => $port, - 'password' => $password, - 'database' => $database, - 'timeout' => $timeout, + 'host' => $host, + 'port' => $port, + 'password' => $password, + 'database' => $database, + 'timeout' => $timeout, + 'persistent' => $persistent, ]; if ($prefix !== null) { @@ -176,13 +178,11 @@ public function open($path, $name): bool $redis = new Redis(); - if ( - ! $redis->connect( - $this->savePath['host'], - $this->savePath['port'], - $this->savePath['timeout'], - ) - ) { + $funcConnection = isset($this->savePath['persistent']) && $this->savePath['persistent'] === true + ? 'pconnect' + : 'connect'; + + if ($redis->{$funcConnection}($this->savePath['host'], $this->savePath['port'], $this->savePath['timeout']) === false) { $this->logger->error('Session: Unable to connect to Redis with the configured settings.'); } elseif (isset($this->savePath['password']) && ! $redis->auth($this->savePath['password'])) { $this->logger->error('Session: Unable to authenticate to Redis instance.'); diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 7e38a81e97de..9244385a908e 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -163,81 +163,133 @@ public static function provideSetSavePath(): iterable 'w/o protocol' => [ '127.0.0.1:6379', [ - 'host' => 'tcp://127.0.0.1', - 'port' => 6379, - 'password' => null, - 'database' => 0, - 'timeout' => 0.0, + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => null, + 'database' => 0, + 'timeout' => 0.0, + 'persistent' => null, ], ], 'tls auth' => [ 'tls://127.0.0.1:6379?auth=password', [ - 'host' => 'tls://127.0.0.1', - 'port' => 6379, - 'password' => 'password', - 'database' => 0, - 'timeout' => 0.0, + 'host' => 'tls://127.0.0.1', + 'port' => 6379, + 'password' => 'password', + 'database' => 0, + 'timeout' => 0.0, + 'persistent' => null, ], ], 'tcp auth' => [ 'tcp://127.0.0.1:6379?auth=password', [ - 'host' => 'tcp://127.0.0.1', - 'port' => 6379, - 'password' => 'password', - 'database' => 0, - 'timeout' => 0.0, + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => 'password', + 'database' => 0, + 'timeout' => 0.0, + 'persistent' => null, ], ], 'timeout float' => [ 'tcp://127.0.0.1:6379?timeout=2.5', [ - 'host' => 'tcp://127.0.0.1', - 'port' => 6379, - 'password' => null, - 'database' => 0, - 'timeout' => 2.5, + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => null, + 'database' => 0, + 'timeout' => 2.5, + 'persistent' => null, ], ], 'timeout int' => [ 'tcp://127.0.0.1:6379?timeout=10', [ - 'host' => 'tcp://127.0.0.1', - 'port' => 6379, - 'password' => null, - 'database' => 0, - 'timeout' => 10.0, + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => null, + 'database' => 0, + 'timeout' => 10.0, + 'persistent' => null, ], ], 'auth acl' => [ 'tcp://localhost:6379?auth[user]=redis-admin&auth[pass]=admin-password', [ - 'host' => 'tcp://localhost', - 'port' => 6379, - 'password' => ['user' => 'redis-admin', 'pass' => 'admin-password'], - 'database' => 0, - 'timeout' => 0.0, + 'host' => 'tcp://localhost', + 'port' => 6379, + 'password' => ['user' => 'redis-admin', 'pass' => 'admin-password'], + 'database' => 0, + 'timeout' => 0.0, + 'persistent' => null, ], ], 'unix domain socket' => [ 'unix:///tmp/redis.sock', [ - 'host' => '/tmp/redis.sock', - 'port' => 0, - 'password' => null, - 'database' => 0, - 'timeout' => 0.0, + 'host' => '/tmp/redis.sock', + 'port' => 0, + 'password' => null, + 'database' => 0, + 'timeout' => 0.0, + 'persistent' => null, ], ], 'unix domain socket w/o protocol' => [ '/tmp/redis.sock', [ - 'host' => '/tmp/redis.sock', - 'port' => 0, - 'password' => null, - 'database' => 0, - 'timeout' => 0.0, + 'host' => '/tmp/redis.sock', + 'port' => 0, + 'password' => null, + 'database' => 0, + 'timeout' => 0.0, + 'persistent' => null, + ], + ], + 'persistent connection with numeric one' => [ + 'tcp://127.0.0.1:6379?timeout=10&persistent=1', + [ + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => null, + 'database' => 0, + 'timeout' => 10.0, + 'persistent' => true, + ], + ], + 'no persistent connection with numeric zero' => [ + 'tcp://127.0.0.1:6379?timeout=10&persistent=0', + [ + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => null, + 'database' => 0, + 'timeout' => 10.0, + 'persistent' => false, + ], + ], + 'persistent connection with boolean true' => [ + 'tcp://127.0.0.1:6379?timeout=10&persistent=true', + [ + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => null, + 'database' => 0, + 'timeout' => 10.0, + 'persistent' => true, + ], + ], + 'persistent connection with boolean false' => [ + 'tcp://127.0.0.1:6379?timeout=10&persistent=false', + [ + 'host' => 'tcp://127.0.0.1', + 'port' => 6379, + 'password' => null, + 'database' => 0, + 'timeout' => 10.0, + 'persistent' => false, ], ], ]; diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 2fb147b2d1b8..c1fe14c0fcb6 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -270,6 +270,7 @@ Libraries - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. +- **Session:** Added ``persistent`` config item to redis handler. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` - **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. - **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. From 84bd79564f665bafa18ad8ef4f6d44bcf86b6437 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 20 Jan 2026 09:19:35 +0100 Subject: [PATCH 74/84] fix: rector --- tests/system/Database/Live/OCI8/LastInsertIDTest.php | 2 +- tests/system/Database/Live/PreparedQueryTest.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/system/Database/Live/OCI8/LastInsertIDTest.php b/tests/system/Database/Live/OCI8/LastInsertIDTest.php index 716cef2ff09d..0a041fbcd073 100644 --- a/tests/system/Database/Live/OCI8/LastInsertIDTest.php +++ b/tests/system/Database/Live/OCI8/LastInsertIDTest.php @@ -79,7 +79,7 @@ public function testGetInsertIDWithHasCommentQuery(): void public function testGetInsertIDWithPreparedQuery(): void { - $query = $this->db->prepare(static function ($db) { + $query = $this->db->prepare(static function ($db): Query { $sql = 'INSERT INTO "db_job" ("name", "description") VALUES (?, ?)'; return (new Query($db))->setQuery($sql); diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index b645c5992413..c66202c191af 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -83,7 +83,7 @@ public function testPrepareReturnsPreparedQuery(): void public function testPrepareReturnsManualPreparedQuery(): void { - $this->query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db): Query { $sql = "INSERT INTO {$db->protectIdentifiers($db->DBPrefix . 'user')} (" . $db->protectIdentifiers('name') . ', ' . $db->protectIdentifiers('email') . ', ' @@ -127,7 +127,7 @@ public function testExecuteRunsQueryAndReturnsTrue(): void public function testExecuteRunsQueryManualAndReturnsTrue(): void { - $this->query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db): Query { $sql = "INSERT INTO {$db->protectIdentifiers($db->DBPrefix . 'user')} (" . $db->protectIdentifiers('name') . ', ' . $db->protectIdentifiers('email') . ', ' @@ -164,7 +164,7 @@ public function testExecuteRunsQueryAndReturnsFalse(): void public function testExecuteRunsQueryManualAndReturnsFalse(): void { - $this->query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db): Query { $sql = "INSERT INTO {$db->protectIdentifiers($db->DBPrefix . 'without_auto_increment')} (" . $db->protectIdentifiers('key') . ', ' . $db->protectIdentifiers('value') @@ -200,7 +200,7 @@ public function testExecuteSelectQueryAndCheckTypeAndResult(): void public function testExecuteSelectQueryManualAndCheckTypeAndResult(): void { - $this->query = $this->db->prepare(static function ($db) { + $this->query = $this->db->prepare(static function ($db): Query { $sql = 'SELECT ' . $db->protectIdentifiers('name') . ', ' . $db->protectIdentifiers('email') . ', ' From fc0cb1924a3a9896423db76577e050fd6773fa69 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 30 Jan 2026 14:37:48 +0800 Subject: [PATCH 75/84] refactor: Use native return types instead of using `#[ReturnTypeWillChange]` (#9900) * refactor: Use native return types instead of using `#[ReturnTypeWillChange]` * Add changelog * Fix some errors in psalm --- psalm-baseline.xml | 5 ++--- system/Cookie/Cookie.php | 6 +----- system/Entity/Entity.php | 4 +--- system/Files/File.php | 10 ++++------ system/I18n/TimeLegacy.php | 6 +----- system/I18n/TimeTrait.php | 16 ++++++---------- system/Session/Handlers/ArrayHandler.php | 13 ++----------- .../Handlers/Database/PostgreHandler.php | 6 +----- system/Session/Handlers/DatabaseHandler.php | 10 ++-------- system/Session/Handlers/FileHandler.php | 12 ++---------- system/Session/Handlers/MemcachedHandler.php | 12 ++---------- system/Session/Handlers/RedisHandler.php | 12 ++---------- user_guide_src/source/changelogs/v4.7.0.rst | 18 ++++++++++++++++++ 13 files changed, 44 insertions(+), 86 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 160ba4d97cd8..7a03f82c5e21 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + |parser_callable_string|parser_callable>]]> @@ -64,8 +64,7 @@ - - + diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php index 73670ce4e7b1..e144d84ba031 100644 --- a/system/Cookie/Cookie.php +++ b/system/Cookie/Cookie.php @@ -20,7 +20,6 @@ use CodeIgniter\I18n\Time; use Config\Cookie as CookieConfig; use DateTimeInterface; -use ReturnTypeWillChange; /** * A `Cookie` class represents an immutable HTTP cookie value object. @@ -606,12 +605,9 @@ public function offsetExists($offset): bool * * @param string $offset * - * @return bool|int|string - * * @throws InvalidArgumentException */ - #[ReturnTypeWillChange] - public function offsetGet($offset) + public function offsetGet($offset): bool|int|string { if (! $this->offsetExists($offset)) { throw new InvalidArgumentException(sprintf('Undefined offset "%s".', $offset)); diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 6541d985d33a..43182cfb9b2a 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -33,7 +33,6 @@ use DateTimeInterface; use Exception; use JsonSerializable; -use ReturnTypeWillChange; use Traversable; use UnitEnum; @@ -602,8 +601,7 @@ protected function dataCaster(): ?DataCaster * * @return array */ - #[ReturnTypeWillChange] - public function jsonSerialize() + public function jsonSerialize(): array { return $this->toArray(); } diff --git a/system/Files/File.php b/system/Files/File.php index a493b59e2a32..83a304f26e99 100644 --- a/system/Files/File.php +++ b/system/Files/File.php @@ -17,7 +17,7 @@ use CodeIgniter\Files\Exceptions\FileNotFoundException; use CodeIgniter\I18n\Time; use Config\Mimes; -use ReturnTypeWillChange; +use RuntimeException; use SplFileInfo; /** @@ -59,13 +59,11 @@ public function __construct(string $path, bool $checkFile = false) * * Implementations SHOULD return the value stored in the "size" key of * the file in the $_FILES array if available, as PHP calculates this based - * on the actual size transmitted. A RuntimeException will be thrown if the file - * does not exist or an error occurs. + * on the actual size transmitted. * - * @return false|int The file size in bytes, or false on failure + * @throws RuntimeException if the file does not exist or an error occurs */ - #[ReturnTypeWillChange] - public function getSize() + public function getSize(): false|int { return $this->size ?? ($this->size = parent::getSize()); } diff --git a/system/I18n/TimeLegacy.php b/system/I18n/TimeLegacy.php index 7dfa0526304a..034cab0dadcf 100644 --- a/system/I18n/TimeLegacy.php +++ b/system/I18n/TimeLegacy.php @@ -15,7 +15,6 @@ use DateTime; use Exception; -use ReturnTypeWillChange; /** * Legacy Time class. @@ -55,12 +54,9 @@ class TimeLegacy extends DateTime * * @param int $timestamp * - * @return static - * * @throws Exception */ - #[ReturnTypeWillChange] - public function setTimestamp($timestamp) + public function setTimestamp($timestamp): static { $time = date('Y-m-d H:i:s', $timestamp); diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 42cd1724b8c4..d9abb16e29a2 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -23,7 +23,6 @@ use IntlCalendar; use IntlDateFormatter; use Locale; -use ReturnTypeWillChange; /** * This trait has properties and methods for Time and TimeLegacy. @@ -238,16 +237,15 @@ public static function create( * Provides a replacement for DateTime's own createFromFormat function, that provides * more flexible timeZone handling * + * @psalm-external-mutation-free + * * @param string $format * @param string $datetime * @param DateTimeZone|string|null $timezone * - * @return static - * * @throws Exception */ - #[ReturnTypeWillChange] - public static function createFromFormat($format, $datetime, $timezone = null) + public static function createFromFormat($format, $datetime, $timezone = null): static { if (! $date = parent::createFromFormat($format, $datetime)) { throw I18nException::forInvalidFormat($format); @@ -673,16 +671,14 @@ protected function setValue(string $name, $value) * * @param DateTimeZone|string $timezone * - * @return static - * * @throws Exception */ - #[ReturnTypeWillChange] - public function setTimezone($timezone) + public function setTimezone($timezone): static { $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); + $dateTime = $this->toDateTime()->setTimezone($timezone); - return static::createFromInstance($this->toDateTime()->setTimezone($timezone), $this->locale); + return static::createFromInstance($dateTime, $this->locale); } // -------------------------------------------------------------------- diff --git a/system/Session/Handlers/ArrayHandler.php b/system/Session/Handlers/ArrayHandler.php index 0db48bb4b0c2..2643173cc742 100644 --- a/system/Session/Handlers/ArrayHandler.php +++ b/system/Session/Handlers/ArrayHandler.php @@ -13,8 +13,6 @@ namespace CodeIgniter\Session\Handlers; -use ReturnTypeWillChange; - /** * Session handler using static array for storage. * Intended only for use during testing. @@ -41,12 +39,8 @@ public function open($path, $name): bool * Reads the session data from the session storage, and returns the results. * * @param string $id The session ID. - * - * @return false|string Returns an encoded string of the read data. - * If nothing was read, it must return false. */ - #[ReturnTypeWillChange] - public function read($id) + public function read($id): string { return ''; } @@ -85,11 +79,8 @@ public function destroy($id): bool * * @param int $max_lifetime Sessions that have not updated * for the last max_lifetime seconds will be removed. - * - * @return false|int Returns the number of deleted sessions on success, or false on failure. */ - #[ReturnTypeWillChange] - public function gc($max_lifetime) + public function gc($max_lifetime): int { return 1; } diff --git a/system/Session/Handlers/Database/PostgreHandler.php b/system/Session/Handlers/Database/PostgreHandler.php index be0621efb945..1471d44d1860 100644 --- a/system/Session/Handlers/Database/PostgreHandler.php +++ b/system/Session/Handlers/Database/PostgreHandler.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Session\Handlers\DatabaseHandler; -use ReturnTypeWillChange; /** * Session handler for Postgre @@ -59,11 +58,8 @@ protected function prepareData(string $data): string * * @param int $max_lifetime Sessions that have not updated * for the last max_lifetime seconds will be removed. - * - * @return false|int Returns the number of deleted sessions on success, or false on failure. */ - #[ReturnTypeWillChange] - public function gc($max_lifetime) + public function gc($max_lifetime): false|int { $separator = '\''; $interval = implode($separator, ['', "{$max_lifetime} second", '']); diff --git a/system/Session/Handlers/DatabaseHandler.php b/system/Session/Handlers/DatabaseHandler.php index e19986461384..c4ce2b8222e7 100644 --- a/system/Session/Handlers/DatabaseHandler.php +++ b/system/Session/Handlers/DatabaseHandler.php @@ -18,7 +18,6 @@ use CodeIgniter\Session\Exceptions\SessionException; use Config\Database; use Config\Session as SessionConfig; -use ReturnTypeWillChange; /** * Base database session handler. @@ -107,12 +106,8 @@ public function open($path, $name): bool * Reads the session data from the session storage, and returns the results. * * @param string $id The session ID - * - * @return false|string Returns an encoded string of the read data. - * If nothing was read, it must return false. */ - #[ReturnTypeWillChange] - public function read($id) + public function read($id): false|string { if ($this->lockSession($id) === false) { $this->fingerprint = md5(''); @@ -281,8 +276,7 @@ public function destroy($id): bool * * @return false|int Returns the number of deleted sessions on success, or false on failure. */ - #[ReturnTypeWillChange] - public function gc($max_lifetime) + public function gc($max_lifetime): false|int { return $this->db->table($this->table)->where( 'timestamp <', diff --git a/system/Session/Handlers/FileHandler.php b/system/Session/Handlers/FileHandler.php index 3e400fb9af45..f5e9261af0f4 100644 --- a/system/Session/Handlers/FileHandler.php +++ b/system/Session/Handlers/FileHandler.php @@ -16,7 +16,6 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; use Config\Session as SessionConfig; -use ReturnTypeWillChange; /** * Session handler using file system for storage. @@ -115,12 +114,8 @@ public function open($path, $name): bool * Reads the session data from the session storage, and returns the results. * * @param string $id The session ID. - * - * @return false|string Returns an encoded string of the read data. - * If nothing was read, it must return false. */ - #[ReturnTypeWillChange] - public function read($id) + public function read($id): false|string { // This might seem weird, but PHP 5.6 introduced session_reset(), // which re-reads session data @@ -267,11 +262,8 @@ public function destroy($id): bool * * @param int $max_lifetime Sessions that have not updated * for the last max_lifetime seconds will be removed. - * - * @return false|int Returns the number of deleted sessions on success, or false on failure. */ - #[ReturnTypeWillChange] - public function gc($max_lifetime) + public function gc($max_lifetime): false|int { if (! is_dir($this->savePath) || ($directory = opendir($this->savePath)) === false) { $this->logger->debug("Session: Garbage collector couldn't list files under directory '" . $this->savePath . "'."); diff --git a/system/Session/Handlers/MemcachedHandler.php b/system/Session/Handlers/MemcachedHandler.php index 90d02f6f394b..6e28e6c9488e 100644 --- a/system/Session/Handlers/MemcachedHandler.php +++ b/system/Session/Handlers/MemcachedHandler.php @@ -17,7 +17,6 @@ use CodeIgniter\Session\Exceptions\SessionException; use Config\Session as SessionConfig; use Memcached; -use ReturnTypeWillChange; /** * Session handler using Memcached for persistence. @@ -138,12 +137,8 @@ public function open($path, $name): bool * Reads the session data from the session storage, and returns the results. * * @param string $id The session ID. - * - * @return false|string Returns an encoded string of the read data. - * If nothing was read, it must return false. */ - #[ReturnTypeWillChange] - public function read($id) + public function read($id): false|string { if (isset($this->memcached) && $this->lockSession($id)) { if (! isset($this->sessionID)) { @@ -243,11 +238,8 @@ public function destroy($id): bool * * @param int $max_lifetime Sessions that have not updated * for the last max_lifetime seconds will be removed. - * - * @return false|int Returns the number of deleted sessions on success, or false on failure. */ - #[ReturnTypeWillChange] - public function gc($max_lifetime) + public function gc($max_lifetime): int { return 1; } diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 213272bb87c7..4a6d8a6b8327 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -18,7 +18,6 @@ use Config\Session as SessionConfig; use Redis; use RedisException; -use ReturnTypeWillChange; /** * Session handler using Redis for persistence. @@ -204,13 +203,9 @@ public function open($path, $name): bool * * @param string $id The session ID. * - * @return false|string Returns an encoded string of the read data. - * If nothing was read, it must return false. - * * @throws RedisException */ - #[ReturnTypeWillChange] - public function read($id) + public function read($id): false|string { if (isset($this->redis) && $this->lockSession($id)) { if (! isset($this->sessionID)) { @@ -333,11 +328,8 @@ public function destroy($id): bool * * @param int $max_lifetime Sessions that have not updated * for the last max_lifetime seconds will be removed. - * - * @return false|int Returns the number of deleted sessions on success, or false on failure. */ - #[ReturnTypeWillChange] - public function gc($max_lifetime) + public function gc($max_lifetime): int { return 1; } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index c1fe14c0fcb6..400f181f3244 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -217,6 +217,24 @@ Method Signature Changes - Added native return types to ``CodeIgniter\Debug\Toolbar`` methods: - ``prepare(): void`` - ``respond(): void`` +- Methods previously having ``#[ReturnTypeWillChange]`` attribute have been updated to include the native return types: + - ``CodeIgniter\Cookie\Cookie::offsetGet($offset): bool|int|string`` + - ``CodeIgniter\Entity\Entity::jsonSerialize(): array`` + - ``CodeIgniter\Files\File::getSize(): false|int`` + - ``CodeIgniter\I18n\TimeLegacy::setTimestamp($timestamp): static`` + - ``CodeIgniter\I18n\TimeTrait::createFromFormat($format, $time, $timezone = null): static`` + - ``CodeIgniter\I18n\TimeTrait::setTimezone($timezone): static`` + - ``CodeIgniter\Session\Handlers\Database\PostgreHandler::gc($max_lifetime): false|int`` + - ``CodeIgniter\Session\Handlers\ArrayHandler::read($id): string`` + - ``CodeIgniter\Session\Handlers\ArrayHandler::gc($max_lifetime): int`` + - ``CodeIgniter\Session\Handlers\DatabaseHandler::read($id): false|string`` + - ``CodeIgniter\Session\Handlers\DatabaseHandler::gc($max_lifetime): false|int`` + - ``CodeIgniter\Session\Handlers\FileHandler::read($id): false|string`` + - ``CodeIgniter\Session\Handlers\FileHandler::gc($max_lifetime): false|int`` + - ``CodeIgniter\Session\Handlers\MemcachedHandler::read($id): false|string`` + - ``CodeIgniter\Session\Handlers\MemcachedHandler::gc($max_lifetime): int`` + - ``CodeIgniter\Session\Handlers\RedisHandler::read($id): false|string`` + - ``CodeIgniter\Session\Handlers\RedisHandler::gc($max_lifetime): int`` Property Signature Changes ========================== From 819a02a503352b816b2cc46a8703ff1d2bdbea84 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Fri, 30 Jan 2026 08:11:25 +0100 Subject: [PATCH 76/84] feat: FrankenPHP Worker Mode (#9889) Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: neznaika0 --- app/Config/Optimize.php | 2 + app/Config/WorkerMode.php | 50 +++ system/Boot.php | 47 +++ system/Cache/Handlers/BaseHandler.php | 22 ++ system/Cache/Handlers/MemcachedHandler.php | 54 ++- system/Cache/Handlers/PredisHandler.php | 34 ++ system/Cache/Handlers/RedisHandler.php | 46 ++- system/CodeIgniter.php | 26 +- system/Commands/Worker/Views/Caddyfile.tpl | 47 +++ .../Worker/Views/frankenphp-worker.php.tpl | 129 ++++++++ system/Commands/Worker/WorkerInstall.php | 138 ++++++++ system/Commands/Worker/WorkerUninstall.php | 120 +++++++ system/Config/BaseService.php | 60 ++++ system/Database/BaseConnection.php | 40 +++ system/Database/Config.php | 39 +++ system/Database/MySQLi/Connection.php | 12 - system/Database/OCI8/Connection.php | 24 +- system/Database/Postgre/Connection.php | 18 +- system/Database/SQLSRV/Connection.php | 12 - system/Database/SQLite3/Connection.php | 12 - system/Debug/Toolbar.php | 13 + system/Debug/Toolbar/Collectors/Database.php | 9 + system/Events/Events.php | 11 + system/Session/Handlers/MemcachedHandler.php | 28 +- system/Session/Handlers/RedisHandler.php | 32 +- system/Session/PersistsConnection.php | 85 +++++ .../system/Cache/Handlers/FileHandlerTest.php | 10 + .../Cache/Handlers/MemcachedHandlerTest.php | 15 + .../Cache/Handlers/PredisHandlerTest.php | 15 + .../Cache/Handlers/RedisHandlerTest.php | 15 + tests/system/CodeIgniterTest.php | 23 ++ tests/system/Commands/WorkerCommandsTest.php | 183 +++++++++++ tests/system/CommonSingleServiceTest.php | 3 + tests/system/Config/ServicesTest.php | 61 ++++ tests/system/Database/Live/PingTest.php | 70 ++++ tests/system/Database/Live/WorkerModeTest.php | 61 ++++ tests/system/Events/EventsTest.php | 14 + .../Handlers/Database/RedisHandlerTest.php | 65 ++++ user_guide_src/source/changelogs/v4.7.0.rst | 18 +- user_guide_src/source/concepts/autoloader.rst | 2 + user_guide_src/source/concepts/factories.rst | 26 +- user_guide_src/source/database/connecting.rst | 10 +- .../source/installation/deployment.rst | 2 + user_guide_src/source/installation/index.rst | 1 + .../source/installation/running.rst | 33 ++ .../source/installation/worker_mode.rst | 307 ++++++++++++++++++ 46 files changed, 1912 insertions(+), 132 deletions(-) create mode 100644 app/Config/WorkerMode.php create mode 100644 system/Commands/Worker/Views/Caddyfile.tpl create mode 100644 system/Commands/Worker/Views/frankenphp-worker.php.tpl create mode 100644 system/Commands/Worker/WorkerInstall.php create mode 100644 system/Commands/Worker/WorkerUninstall.php create mode 100644 system/Session/PersistsConnection.php create mode 100644 tests/system/Commands/WorkerCommandsTest.php create mode 100644 tests/system/Database/Live/PingTest.php create mode 100644 tests/system/Database/Live/WorkerModeTest.php create mode 100644 user_guide_src/source/installation/worker_mode.rst diff --git a/app/Config/Optimize.php b/app/Config/Optimize.php index 481e645f57b2..8b93eb1c9ffb 100644 --- a/app/Config/Optimize.php +++ b/app/Config/Optimize.php @@ -7,6 +7,8 @@ * * NOTE: This class does not extend BaseConfig for performance reasons. * So you cannot replace the property values with Environment Variables. + * + * WARNING: Do not use these options when running the app in the Worker Mode. */ class Optimize { diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php new file mode 100644 index 000000000000..1c005f63a67a --- /dev/null +++ b/app/Config/WorkerMode.php @@ -0,0 +1,50 @@ + + */ + public array $persistentServices = [ + 'autoloader', + 'locator', + 'exceptions', + 'commands', + 'codeigniter', + 'superglobals', + 'routes', + 'cache', + ]; + + /** + * Force Garbage Collection + * + * Whether to force garbage collection after each request. + * Helps prevent memory leaks at a small performance cost. + */ + public bool $forceGarbageCollection = true; +} diff --git a/system/Boot.php b/system/Boot.php index 76f9fee8966d..4e0c94ddd19e 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -76,6 +76,39 @@ public static function bootWeb(Paths $paths): int return EXIT_SUCCESS; } + /** + * Bootstrap for FrankenPHP worker mode. + * + * This method performs one-time initialization for worker mode, + * loading everything except the CodeIgniter instance, which should + * be created fresh for each request. + * + * @used-by `public/frankenphp-worker.php` + */ + public static function bootWorker(Paths $paths): CodeIgniter + { + static::definePathConstants($paths); + if (! defined('APP_NAMESPACE')) { + static::loadConstants(); + } + static::checkMissingExtensions(); + + static::loadDotEnv($paths); + static::defineEnvironment(); + static::loadEnvironmentBootstrap($paths); + + static::loadCommonFunctions(); + static::loadAutoloader(); + static::setExceptionHandler(); + static::initializeKint(); + + static::checkOptimizationsForWorker(); + + static::autoloadHelpers(); + + return Boot::initializeCodeIgniter(); + } + /** * Used by command line scripts other than * * `spark` @@ -333,6 +366,20 @@ protected static function checkMissingExtensions(): void exit(EXIT_ERROR); } + protected static function checkOptimizationsForWorker(): void + { + if (class_exists(Optimize::class)) { + $optimize = new Optimize(); + + if ($optimize->configCacheEnabled || $optimize->locatorCacheEnabled) { + echo 'Optimization settings (configCacheEnabled, locatorCacheEnabled) ' + . 'must be disabled in Config\Optimize when running in Worker Mode.'; + + exit(EXIT_ERROR); + } + } + } + protected static function initializeKint(): void { service('autoloader')->initializeKint(CI_DEBUG); diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index 4f7022d3c249..7cf048c6a92b 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -87,4 +87,26 @@ public function remember(string $key, int $ttl, Closure $callback): mixed return $value; } + + /** + * Check if connection is alive. + * + * Default implementation for handlers that don't require connection management. + * Handlers with persistent connections (Redis, Predis, Memcached) should override this. + */ + public function ping(): bool + { + return true; + } + + /** + * Reconnect to the cache server. + * + * Default implementation for handlers that don't require connection management. + * Handlers with persistent connections (Redis, Predis, Memcached) should override this. + */ + public function reconnect(): bool + { + return true; + } } diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 14bc8e80a144..10e2ecae0a96 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -57,18 +57,6 @@ public function __construct(Cache $config) $this->config = array_merge($this->config, $config->memcached); } - /** - * Closes the connection to Memcache(d) if present. - */ - public function __destruct() - { - if ($this->memcached instanceof Memcached) { - $this->memcached->quit(); - } elseif ($this->memcached instanceof Memcache) { - $this->memcached->close(); - } - } - public function initialize(): void { try { @@ -230,4 +218,46 @@ public function isSupported(): bool { return extension_loaded('memcached') || extension_loaded('memcache'); } + + public function ping(): bool + { + $version = $this->memcached->getVersion(); + + if ($this->memcached instanceof Memcached) { + // Memcached extension returns array with server:port => version + if (! is_array($version)) { + return false; + } + + $serverKey = $this->config['host'] . ':' . $this->config['port']; + + return isset($version[$serverKey]) && $version[$serverKey] !== false; + } + + if ($this->memcached instanceof Memcache) { + // Memcache extension returns string version + return is_string($version) && $version !== ''; + } + + return false; + } + + public function reconnect(): bool + { + if ($this->memcached instanceof Memcached) { + $this->memcached->quit(); + } elseif ($this->memcached instanceof Memcache) { + $this->memcached->close(); + } + + try { + $this->initialize(); + + return true; + } catch (CriticalError $e) { + log_message('error', 'Cache: Memcached reconnection failed: ' . $e->getMessage()); + + return false; + } + } } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 763848b232fc..8919c7a1db2b 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -204,4 +204,38 @@ public function isSupported(): bool { return class_exists(Client::class); } + + public function ping(): bool + { + try { + $result = $this->redis->ping(); + + if (is_object($result)) { + return $result->getPayload() === 'PONG'; + } + + return $result === 'PONG'; + } catch (Exception) { + return false; + } + } + + public function reconnect(): bool + { + try { + $this->redis->disconnect(); + } catch (Exception) { + // Connection already dead, that's fine + } + + try { + $this->initialize(); + + return true; + } catch (CriticalError $e) { + log_message('error', 'Cache: Predis reconnection failed: ' . $e->getMessage()); + + return false; + } + } } diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 7dacf94ad6bb..98cf33651687 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -64,16 +64,6 @@ public function __construct(Cache $config) $this->config = array_merge($this->config, $config->redis); } - /** - * Closes the connection to Redis if present. - */ - public function __destruct() - { - if (isset($this->redis)) { - $this->redis->close(); - } - } - public function initialize(): void { $config = $this->config; @@ -229,4 +219,40 @@ public function isSupported(): bool { return extension_loaded('redis'); } + + public function ping(): bool + { + if (! isset($this->redis)) { + return false; + } + + try { + $result = $this->redis->ping(); + + return in_array($result, [true, '+PONG'], true); + } catch (RedisException) { + return false; + } + } + + public function reconnect(): bool + { + if (isset($this->redis)) { + try { + $this->redis->close(); + } catch (RedisException) { + // Connection already dead, that's fine + } + } + + try { + $this->initialize(); + + return true; + } catch (CriticalError $e) { + log_message('error', 'Cache: Redis reconnection failed: ' . $e->getMessage()); + + return false; + } + } } diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 847020504726..4050f4c139d5 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -95,14 +95,14 @@ class CodeIgniter /** * Current response. * - * @var ResponseInterface + * @var ResponseInterface|null */ protected $response; /** * Router to use. * - * @var Router + * @var Router|null */ protected $router; @@ -116,14 +116,14 @@ class CodeIgniter /** * Controller method to invoke. * - * @var string + * @var string|null */ protected $method; /** * Output handler to use. * - * @var string + * @var string|null */ protected $output; @@ -192,6 +192,24 @@ public function initialize() date_default_timezone_set($this->config->appTimezone ?? 'UTC'); } + /** + * Reset request-specific state for worker mode. + * Clears all request/response data to prepare for the next request. + */ + public function resetForWorkerMode(): void + { + $this->request = null; + $this->response = null; + $this->router = null; + $this->controller = null; + $this->method = null; + $this->output = null; + + // Reset timing + $this->startTime = null; + $this->totalTime = 0; + } + /** * Initializes Kint * diff --git a/system/Commands/Worker/Views/Caddyfile.tpl b/system/Commands/Worker/Views/Caddyfile.tpl new file mode 100644 index 000000000000..2170e572ee07 --- /dev/null +++ b/system/Commands/Worker/Views/Caddyfile.tpl @@ -0,0 +1,47 @@ +# CodeIgniter 4 - FrankenPHP Worker Mode Configuration +# +# This Caddyfile configures FrankenPHP to run CodeIgniter in worker mode. +# Adjust settings based on your server resources and application needs. +# +# Start with: frankenphp run + +{ + # FrankenPHP worker configuration + frankenphp { + # Worker configuration + worker { + # Path to the worker file + file public/frankenphp-worker.php + + # Number of workers (default: 2x CPU cores) + # Adjust based on your server capacity + # num 16 + + # Watch for PHP code changes (development only) + watch app/**/*.php + watch vendor/**/*.php + watch .env + } + } + + # Disable admin API (recommended for production) + admin off +} + +# HTTP server configuration +:8080 { + # Document root + root * public + + # Enable compression + encode zstd br gzip + + # Route all PHP requests through the worker + php_server { + # Route all requests through the worker + try_files {path} frankenphp-worker.php + } + + # Serve static files + file_server +} diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl new file mode 100644 index 000000000000..4a3c4ecd90b1 --- /dev/null +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -0,0 +1,129 @@ +systemDirectory . '/Boot.php'; + +// One-time boot - loads autoloader, environment, helpers, etc. +$app = Boot::bootWorker($paths); + +// Prevent worker termination on client disconnect +ignore_user_abort(true); + +/** @var WorkerMode $workerConfig */ +$workerConfig = config('WorkerMode'); + +/* + *--------------------------------------------------------------- + * REQUEST HANDLER + *--------------------------------------------------------------- + */ + +$handler = static function () use ($app, $workerConfig) { + // Reconnect database connections before handling request + DatabaseConfig::reconnectForWorkerMode(); + + // Reconnect cache connection before handling request + Services::reconnectCacheForWorkerMode(); + + // Reset request-specific state + $app->resetForWorkerMode(); + + // Update superglobals with fresh request data + service('superglobals') + ->setServerArray($_SERVER) + ->setGetArray($_GET) + ->setPostArray($_POST) + ->setCookieArray($_COOKIE) + ->setFilesArray($_FILES) + ->setRequestArray($_REQUEST); + + try { + $app->run(); + } catch (Throwable $e) { + Services::exceptions()->exceptionHandler($e); + } + + if ($workerConfig->forceGarbageCollection) { + // Force garbage collection + gc_collect_cycles(); + } +}; + +/* + *--------------------------------------------------------------- + * WORKER REQUEST LOOP + *--------------------------------------------------------------- + */ + +while (frankenphp_handle_request($handler)) { + // Close session + if (Services::has('session')) { + Services::session()->close(); + } + + // Cleanup connections with uncommitted transactions + DatabaseConfig::cleanupForWorkerMode(); + + // Reset factories + Factories::reset(); + + // Reset services except persistent ones + Services::resetForWorkerMode($workerConfig); + + if (CI_DEBUG) { + Events::cleanupForWorkerMode(); + Services::toolbar()->reset(); + } +} diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php new file mode 100644 index 000000000000..99afdc9f69eb --- /dev/null +++ b/system/Commands/Worker/WorkerInstall.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Worker; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * Install Worker Mode for FrankenPHP. + * + * This command sets up the necessary files to run CodeIgniter 4 + * in FrankenPHP worker mode for improved performance. + */ +class WorkerInstall extends BaseCommand +{ + protected $group = 'Worker Mode'; + protected $name = 'worker:install'; + protected $description = 'Install FrankenPHP worker mode by creating necessary configuration files'; + protected $usage = 'worker:install [options]'; + protected $options = [ + '--force' => 'Overwrite existing files', + ]; + + /** + * Template file mappings (template => destination path) + * + * @var array + */ + private array $templates = [ + 'frankenphp-worker.php.tpl' => 'public/frankenphp-worker.php', + 'Caddyfile.tpl' => 'Caddyfile', + ]; + + public function run(array $params) + { + $force = array_key_exists('force', $params) || CLI::getOption('force'); + + CLI::write('Setting up FrankenPHP Worker Mode', 'yellow'); + CLI::newLine(); + + helper('filesystem'); + + $created = []; + + // Process each template + foreach ($this->templates as $template => $destination) { + $source = SYSTEMPATH . 'Commands/Worker/Views/' . $template; + $target = ROOTPATH . $destination; + + $isFile = is_file($target); + + // Skip if file exists and not forcing overwrite + if (! $force && $isFile) { + continue; + } + + // Read template content + $content = file_get_contents($source); + if ($content === false) { + CLI::error( + "Failed to read template: {$template}", + 'light_gray', + 'red', + ); + CLI::newLine(); + + return EXIT_ERROR; + } + + // Write file to destination + if (! write_file($target, $content)) { + CLI::error( + 'Failed to create file: ' . clean_path($target), + 'light_gray', + 'red', + ); + CLI::newLine(); + + return EXIT_ERROR; + } + + if ($force && $isFile) { + CLI::write(' File overwritten: ' . clean_path($target), 'yellow'); + } else { + CLI::write(' File created: ' . clean_path($target), 'green'); + } + + $created[] = $destination; + } + + // No files were created + if ($created === []) { + CLI::newLine(); + CLI::write('Worker mode files already exist.', 'yellow'); + CLI::write('Use --force to overwrite existing files.', 'yellow'); + CLI::newLine(); + + return EXIT_ERROR; + } + + // Success message + CLI::newLine(); + CLI::write('Worker mode files created successfully!', 'green'); + CLI::newLine(); + + $this->showNextSteps(); + + return EXIT_SUCCESS; + } + + /** + * Display next steps to the user + */ + protected function showNextSteps(): void + { + CLI::write('Next Steps:', 'yellow'); + CLI::newLine(); + + CLI::write('1. Start FrankenPHP:', 'white'); + CLI::write(' frankenphp run', 'green'); + CLI::newLine(); + + CLI::write('2. Test your application:', 'white'); + CLI::write(' curl http://localhost:8080/', 'green'); + CLI::newLine(); + } +} diff --git a/system/Commands/Worker/WorkerUninstall.php b/system/Commands/Worker/WorkerUninstall.php new file mode 100644 index 000000000000..0d083f2e5b49 --- /dev/null +++ b/system/Commands/Worker/WorkerUninstall.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Worker; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; + +/** + * Uninstall Worker Mode for FrankenPHP. + * + * This command removes the files created by the worker:install command. + */ +class WorkerUninstall extends BaseCommand +{ + protected $group = 'Worker Mode'; + protected $name = 'worker:uninstall'; + protected $description = 'Remove FrankenPHP worker mode configuration files'; + protected $usage = 'worker:uninstall [options]'; + protected $options = [ + '--force' => 'Skip confirmation prompt', + ]; + + /** + * Files to remove (must match Install command) + * + * @var list + */ + private array $files = [ + 'public/frankenphp-worker.php', + 'Caddyfile', + ]; + + public function run(array $params) + { + $force = array_key_exists('force', $params) || CLI::getOption('force'); + + CLI::write('Uninstalling FrankenPHP Worker Mode', 'yellow'); + CLI::newLine(); + + // Find existing files + $existing = []; + + foreach ($this->files as $file) { + $path = ROOTPATH . $file; + if (is_file($path)) { + $existing[] = $file; + } + } + + // No files to remove + if ($existing === []) { + CLI::write('No worker mode files found to remove.', 'yellow'); + CLI::newLine(); + + return EXIT_SUCCESS; + } + + // Show files that will be removed + CLI::write('The following files will be removed:', 'yellow'); + + foreach ($existing as $file) { + CLI::write(' - ' . $file, 'white'); + } + CLI::newLine(); + + // Confirm deletion unless --force is used + if (! $force) { + $confirm = CLI::prompt('Are you sure you want to remove these files?', ['y', 'n']); + CLI::newLine(); + + if ($confirm !== 'y') { + CLI::write('Uninstall cancelled.', 'yellow'); + CLI::newLine(); + + return EXIT_ERROR; + } + } + + $removed = []; + + // Remove each file + foreach ($existing as $file) { + $path = ROOTPATH . $file; + + if (! @unlink($path)) { + CLI::error('Failed to remove file: ' . clean_path($path), 'light_gray', 'red'); + + continue; + } + + CLI::write(' File removed: ' . clean_path($path), 'green'); + $removed[] = $file; + } + + // Summary + CLI::newLine(); + if ($removed === []) { + CLI::error('No files were removed.'); + CLI::newLine(); + + return EXIT_ERROR; + } + + CLI::write('Worker mode files removed successfully!', 'green'); + CLI::newLine(); + + return EXIT_SUCCESS; + } +} diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 0fb4c3c7c3ad..483da962d5ad 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -80,6 +80,7 @@ use Config\Toolbar as ConfigToolbar; use Config\Validation as ConfigValidation; use Config\View as ConfigView; +use Config\WorkerMode; /** * Services Configuration file. @@ -202,6 +203,18 @@ public static function get(string $key): ?object return static::$instances[$key] ?? static::__callStatic($key, []); } + /** + * Checks if a service instance has been created. + * + * @param string $key Identifier of the entry to check. + * + * @return bool True if the service instance exists, false otherwise. + */ + public static function has(string $key): bool + { + return isset(static::$instances[$key]); + } + /** * Sets an entry. * @@ -361,6 +374,53 @@ public static function reset(bool $initAutoloader = true) } } + /** + * Reconnect cache connection for worker mode at the start of a request. + * Checks if cache connection is alive and reconnects if needed. + * + * This should be called at the beginning of each request in worker mode, + * before the application runs. + */ + public static function reconnectCacheForWorkerMode(): void + { + if (! isset(static::$instances['cache'])) { + return; + } + + $cache = static::$instances['cache']; + + if (! $cache->ping()) { + $cache->reconnect(); + } + } + + /** + * Resets all services except those in the persistent list. + * Used for worker mode to preserve expensive-to-initialize services. + * + * Called at the END of each request to clean up state. + */ + public static function resetForWorkerMode(WorkerMode $config): void + { + // Reset mocks (testing only, safe to clear) + static::$mocks = []; + + // Reset factories + static::$factories = []; + + // Process each service instance + $persistentInstances = []; + + foreach (static::$instances as $serviceName => $service) { + // Persist services in the persistent list + if (in_array($serviceName, $config->persistentServices, true)) { + $persistentInstances[$serviceName] = $service; + } + } + + static::$instances = $persistentInstances; + } + /** * Resets any mock and shared instances for a single service. * diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index f09175d59b46..8e8d0dd43d24 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -486,6 +486,20 @@ public function close() } } + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + * + * @return void + */ + public function reconnect() + { + if ($this->ping() === false) { + $this->close(); + $this->initialize(); + } + } + /** * Platform dependent way method for closing the connection. * @@ -493,6 +507,32 @@ public function close() */ abstract protected function _close(); + /** + * Check if the connection is still alive. + */ + public function ping(): bool + { + if ($this->connID === false) { + return false; + } + + return $this->_ping(); + } + + /** + * Driver-specific ping implementation. + */ + protected function _ping(): bool + { + try { + $result = $this->simpleQuery('SELECT 1'); + + return $result !== false; + } catch (DatabaseException) { + return false; + } + } + /** * Create a persistent database connection. * diff --git a/system/Database/Config.php b/system/Database/Config.php index fcf700ee3bcb..413d0b3b5de8 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -152,4 +152,43 @@ protected static function ensureFactory() static::$factory = new Database(); } + + /** + * Reconnect database connections for worker mode at the start of a request. + * + * This should be called at the beginning of each request in worker mode, + * before the application runs. + */ + public static function reconnectForWorkerMode(): void + { + foreach (static::$instances as $connection) { + $connection->reconnect(); + } + } + + /** + * Cleanup database connections for worker mode. + * + * Rolls back any uncommitted transactions and resets transaction status + * to ensure a clean state for the next request. + * + * Uncommitted transactions at this point indicate a bug in the + * application code (transactions should be completed before request ends). + * + * Called at the END of each request to clean up state. + */ + public static function cleanupForWorkerMode(): void + { + foreach (static::$instances as $group => $connection) { + if ($connection->transDepth > 0) { + log_message('error', "Uncommitted transaction detected in database group '{$group}'. Transactions must be completed before request ends."); + + while ($connection->transDepth > 0) { + $connection->transRollback(); + } + } + + $connection->resetTransStatus(); + } + } } diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 2b227cdaa837..b38ef3349eaa 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -234,18 +234,6 @@ public function connect(bool $persistent = false) return false; } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - $this->close(); - $this->initialize(); - } - /** * Close the database connection. * diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 69c3a0d8eee2..dc884588a251 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -150,16 +150,6 @@ public function connect(bool $persistent = false) : $func($this->username, $this->password, $this->DSN, $this->charset); } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - } - /** * Close the database connection. * @@ -176,6 +166,20 @@ protected function _close() oci_close($this->connID); } + /** + * Ping the database connection. + */ + protected function _ping(): bool + { + try { + $result = $this->simpleQuery('SELECT 1 FROM DUAL'); + + return $result !== false; + } catch (DatabaseException) { + return false; + } + } + /** * Select a specific database table to use. */ diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index e60c8b1583d9..295616ab035d 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -143,27 +143,21 @@ private function convertDSN() } /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. + * Close the database connection. * * @return void */ - public function reconnect() + protected function _close() { - if ($this->connID === false || pg_ping($this->connID) === false) { - $this->close(); - $this->initialize(); - } + pg_close($this->connID); } /** - * Close the database connection. - * - * @return void + * Ping the database connection. */ - protected function _close() + protected function _ping(): bool { - pg_close($this->connID); + return pg_ping($this->connID); } /** diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 7e6d0810a3ad..76dd80bdb09f 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -166,18 +166,6 @@ public function getAllErrorMessages(): string return implode("\n", $errors); } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - $this->close(); - $this->initialize(); - } - /** * Close the database connection. * diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index c61bb6c42b49..3865669a9e2d 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -119,18 +119,6 @@ public function connect(bool $persistent = false) } } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - $this->close(); - $this->initialize(); - } - /** * Close the database connection. * diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 257e19e1f602..c35003129764 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -606,4 +606,17 @@ private function shouldDisableToolbar(IncomingRequest $request): bool return false; } + + /** + * Reset all collectors for worker mode. + * Calls reset() on collectors that support it. + */ + public function reset(): void + { + foreach ($this->collectors as $collector) { + if (method_exists($collector, 'reset')) { + $collector->reset(); + } + } + } } diff --git a/system/Debug/Toolbar/Collectors/Database.php b/system/Debug/Toolbar/Collectors/Database.php index be57931b30f9..fdf961d2f974 100644 --- a/system/Debug/Toolbar/Collectors/Database.php +++ b/system/Debug/Toolbar/Collectors/Database.php @@ -254,4 +254,13 @@ private function getConnections(): void { $this->connections = \Config\Database::getConnections(); } + + /** + * Reset collector state for worker mode. + * Clears collected queries between requests. + */ + public function reset(): void + { + static::$queries = []; + } } diff --git a/system/Events/Events.php b/system/Events/Events.php index 4c255f02df05..979f531591fe 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -284,4 +284,15 @@ public static function getPerformanceLogs() { return static::$performanceLog; } + + /** + * Cleanup performance log and request-specific listeners for worker mode. + * + * Called at the END of each request to clean up state. + */ + public static function cleanupForWorkerMode(): void + { + static::$performanceLog = []; + static::removeAllListeners('DBQuery'); + } } diff --git a/system/Session/Handlers/MemcachedHandler.php b/system/Session/Handlers/MemcachedHandler.php index 6e28e6c9488e..478aa88c7a11 100644 --- a/system/Session/Handlers/MemcachedHandler.php +++ b/system/Session/Handlers/MemcachedHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; +use CodeIgniter\Session\PersistsConnection; use Config\Session as SessionConfig; use Memcached; @@ -23,6 +24,8 @@ */ class MemcachedHandler extends BaseHandler { + use PersistsConnection; + /** * Memcached instance. * @@ -82,6 +85,23 @@ public function __construct(SessionConfig $config, string $ipAddress) */ public function open($path, $name): bool { + if ($this->hasPersistentConnection()) { + $memcached = $this->getPersistentConnection(); + $version = $memcached->getVersion(); + + if (is_array($version)) { + foreach ($version as $serverVersion) { + if ($serverVersion !== false) { + $this->memcached = $memcached; + + return true; + } + } + } + + $this->setPersistentConnection(null); + } + $this->memcached = new Memcached(); $this->memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true); // required for touch() usage @@ -130,6 +150,8 @@ public function open($path, $name): bool return false; } + $this->setPersistentConnection($this->memcached); + return true; } @@ -205,12 +227,6 @@ public function close(): bool $this->memcached->delete($this->lockKey); } - if (! $this->memcached->quit()) { - return false; - } - - $this->memcached = null; - return true; } diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 4a6d8a6b8327..4c8f78ba3894 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; +use CodeIgniter\Session\PersistsConnection; use Config\Session as SessionConfig; use Redis; use RedisException; @@ -24,6 +25,8 @@ */ class RedisHandler extends BaseHandler { + use PersistsConnection; + private const DEFAULT_PORT = 6379; private const DEFAULT_PROTOCOL = 'tcp'; @@ -175,6 +178,22 @@ public function open($path, $name): bool return false; } + if ($this->hasPersistentConnection()) { + $redis = $this->getPersistentConnection(); + + try { + $pingReply = $redis->ping(); + + if (in_array($pingReply, [true, '+PONG'], true)) { + $this->redis = $redis; + + return true; + } + } catch (RedisException) { + $this->setPersistentConnection(null); + } + } + $redis = new Redis(); $funcConnection = isset($this->savePath['persistent']) && $this->savePath['persistent'] === true @@ -190,6 +209,7 @@ public function open($path, $name): bool 'Session: Unable to select Redis database with index ' . $this->savePath['database'], ); } else { + $this->setPersistentConnection($redis); $this->redis = $redis; return true; @@ -280,21 +300,13 @@ public function close(): bool try { $pingReply = $this->redis->ping(); - if (($pingReply === true) || ($pingReply === '+PONG')) { - if (isset($this->lockKey) && ! $this->releaseLock()) { - return false; - } - - if (! $this->redis->close()) { - return false; - } + if (in_array($pingReply, [true, '+PONG'], true) && isset($this->lockKey) && ! $this->releaseLock()) { + return false; } } catch (RedisException $e) { $this->logger->error('Session: Got RedisException on close(): ' . $e->getMessage()); } - $this->redis = null; - return true; } diff --git a/system/Session/PersistsConnection.php b/system/Session/PersistsConnection.php new file mode 100644 index 000000000000..716a949f15cd --- /dev/null +++ b/system/Session/PersistsConnection.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Session; + +/** + * Trait for session handlers that need persistent connections. + */ +trait PersistsConnection +{ + /** + * Connection pool keyed by connection identifier. + * Allows multiple configurations to each have their own connection. + * + * @var array + */ + protected static $connectionPool = []; + + /** + * Get connection identifier based on configuration. + * This returns a unique hash for each distinct connection configuration. + */ + protected function getConnectionIdentifier(): string + { + return hash('xxh128', serialize([ + 'class' => static::class, + 'savePath' => $this->savePath, + 'keyPrefix' => $this->keyPrefix, + ])); + } + + /** + * Check if a persistent connection exists for this configuration. + */ + protected function hasPersistentConnection(): bool + { + $identifier = $this->getConnectionIdentifier(); + + return isset(self::$connectionPool[$identifier]); + } + + /** + * Get the persistent connection for this configuration. + */ + protected function getPersistentConnection(): ?object + { + $identifier = $this->getConnectionIdentifier(); + + return self::$connectionPool[$identifier] ?? null; + } + + /** + * Store a connection for persistence. + * + * @param object|null $connection The connection to persist (null to clear). + */ + protected function setPersistentConnection(?object $connection): void + { + $identifier = $this->getConnectionIdentifier(); + + if ($connection === null) { + unset(self::$connectionPool[$identifier]); + } else { + self::$connectionPool[$identifier] = $connection; + } + } + + /** + * Reset all persistent connections (useful for testing). + */ + public static function resetPersistentConnections(): void + { + self::$connectionPool = []; + } +} diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index 6a709d60caf8..16c6042be124 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -388,6 +388,16 @@ public function testGetItemWithCorruptedData(): void $this->assertNull($this->handler->get(self::$key1)); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->assertTrue($this->handler->reconnect()); + } } /** diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index 06ae6112a432..e6bd5dd147ec 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -188,4 +188,19 @@ public function testGetMetaDataMiss(): void { $this->assertNull($this->handler->getMetaData(self::$dummy)); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->handler->save(self::$key1, 'value'); + $this->assertSame('value', $this->handler->get(self::$key1)); + + $this->assertTrue($this->handler->reconnect()); + + $this->assertSame('value', $this->handler->get(self::$key1)); + } } diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index a906457b5c97..135d3ff083de 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -191,4 +191,19 @@ public function testIsSupported(): void { $this->assertTrue($this->handler->isSupported()); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->handler->save(self::$key1, 'value'); + $this->assertSame('value', $this->handler->get(self::$key1)); + + $this->assertTrue($this->handler->reconnect()); + + $this->assertSame('value', $this->handler->get(self::$key1)); + } } diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index bbdb3afc7cd1..d42123c6dd82 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -233,4 +233,19 @@ public function testIsSupported(): void { $this->assertTrue($this->handler->isSupported()); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->handler->save(self::$key1, 'value'); + $this->assertSame('value', $this->handler->get(self::$key1)); + + $this->assertTrue($this->handler->reconnect()); + + $this->assertSame('value', $this->handler->get(self::$key1)); + } } diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index bab5830e6685..e4bc8a9e2cd5 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -1270,4 +1270,27 @@ public function testRouteAttributesDisabledInConfig(): void // But the controller method should still execute $this->assertStringContainsString('Filtered', (string) $output); } + + public function testResetForWorkerMode(): void + { + $config = new App(); + $codeigniter = new MockCodeIgniter($config); + + $this->setPrivateProperty($codeigniter, 'request', service('request')); + $this->setPrivateProperty($codeigniter, 'response', service('response')); + $this->setPrivateProperty($codeigniter, 'output', 'test output'); + + $this->assertNotNull($this->getPrivateProperty($codeigniter, 'request')); + $this->assertNotNull($this->getPrivateProperty($codeigniter, 'response')); + $this->assertNotNull($this->getPrivateProperty($codeigniter, 'output')); + + $codeigniter->resetForWorkerMode(); + + $this->assertNull($this->getPrivateProperty($codeigniter, 'request')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'response')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'router')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'controller')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'method')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'output')); + } } diff --git a/tests/system/Commands/WorkerCommandsTest.php b/tests/system/Commands/WorkerCommandsTest.php new file mode 100644 index 000000000000..be0880f9157d --- /dev/null +++ b/tests/system/Commands/WorkerCommandsTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class WorkerCommandsTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + /** + * @var list + */ + private array $filesToCleanup = [ + 'public/frankenphp-worker.php', + 'Caddyfile', + ]; + + protected function tearDown(): void + { + parent::tearDown(); + + $this->cleanupFiles(); + } + + private function cleanupFiles(): void + { + foreach ($this->filesToCleanup as $file) { + $path = ROOTPATH . $file; + if (is_file($path)) { + @unlink($path); + } + } + } + + public function testWorkerInstallCreatesFiles(): void + { + command('worker:install'); + + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Worker mode files created successfully!', $output); + $this->assertStringContainsString('File created:', $output); + } + + public function testWorkerInstallSkipsExistingFilesWithoutForce(): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command('worker:install'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Worker mode files already exist', $output); + $this->assertStringContainsString('Use --force to overwrite', $output); + } + + public function testWorkerInstallOverwritesWithForce(): void + { + command('worker:install'); + + $workerFile = ROOTPATH . 'public/frankenphp-worker.php'; + file_put_contents($workerFile, 'resetStreamFilterBuffer(); + + command('worker:install --force'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('File overwritten:', $output); + + $content = file_get_contents($workerFile); + $this->assertStringNotContainsString('// Modified content', (string) $content); + $this->assertStringContainsString('FrankenPHP Worker', (string) $content); + } + + public function testWorkerInstallShowsNextSteps(): void + { + command('worker:install'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Next Steps:', $output); + $this->assertStringContainsString('frankenphp run', $output); + $this->assertStringContainsString('http://localhost:8080/', $output); + } + + public function testWorkerUninstallRemovesFiles(): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command('worker:uninstall --force'); + + $this->assertFileDoesNotExist(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileDoesNotExist(ROOTPATH . 'Caddyfile'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Worker mode files removed successfully!', $output); + $this->assertStringContainsString('File removed:', $output); + } + + public function testWorkerUninstallWithNoFilesToRemove(): void + { + $this->cleanupFiles(); + + command('worker:uninstall --force'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('No worker mode files found to remove', $output); + } + + public function testWorkerUninstallListsFilesToRemove(): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command('worker:uninstall --force'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('The following files will be removed:', $output); + $this->assertStringContainsString('public/frankenphp-worker.php', $output); + $this->assertStringContainsString('Caddyfile', $output); + } + + public function testWorkerInstallAndUninstallCycle(): void + { + command('worker:install'); + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + + command('worker:uninstall --force'); + $this->assertFileDoesNotExist(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileDoesNotExist(ROOTPATH . 'Caddyfile'); + } + + public function testWorkerInstallCreatesValidPHPFile(): void + { + command('worker:install'); + + $workerFile = ROOTPATH . 'public/frankenphp-worker.php'; + $this->assertFileExists($workerFile); + + $content = file_get_contents($workerFile); + $this->assertStringStartsWith('assertStringContainsString('frankenphp_handle_request', (string) $content); + $this->assertStringContainsString('DatabaseConfig::reconnectForWorkerMode', (string) $content); + $this->assertStringContainsString('DatabaseConfig::cleanupForWorkerMode', (string) $content); + } + + public function testWorkerInstallCreatesValidCaddyfile(): void + { + command('worker:install'); + + $caddyfile = ROOTPATH . 'Caddyfile'; + $this->assertFileExists($caddyfile); + + $content = file_get_contents($caddyfile); + + $this->assertStringContainsString('frankenphp', (string) $content); + $this->assertStringContainsString('worker', (string) $content); + $this->assertStringContainsString('public/frankenphp-worker.php', (string) $content); + } +} diff --git a/tests/system/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index 014ac12a27fc..e423d1feaee0 100644 --- a/tests/system/CommonSingleServiceTest.php +++ b/tests/system/CommonSingleServiceTest.php @@ -91,9 +91,12 @@ public static function provideServiceNames(): iterable 'reset', 'resetSingle', 'resetServicesCache', + 'resetForWorkerMode', 'injectMock', + 'has', 'encrypter', // Encrypter needs a starter key 'session', // Headers already sent + 'reconnectCacheForWorkerMode', ]; if ($services === []) { diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 37b50890c400..7eef9818a727 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -51,6 +51,7 @@ use Config\Exceptions; use Config\Security as SecurityConfig; use Config\Session as ConfigSession; +use Config\WorkerMode; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -500,4 +501,64 @@ public function testServiceInstance(): void $this->assertInstanceOf(\Config\Services::class, new \Config\Services()); rename(COMPOSER_PATH . '.backup', COMPOSER_PATH); } + + public function testHas(): void + { + $this->assertFalse(Services::has('timer')); + + Services::timer(); + + $this->assertTrue(Services::has('timer')); + } + + public function testHasReturnsFalseForNonExistentService(): void + { + $this->assertFalse(Services::has('nonexistent')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testResetForWorkerMode(): void + { + $config = new WorkerMode(); + $config->persistentServices = ['timer']; + + Services::cache(); + Services::timer(); + Services::typography(); + + $this->assertTrue(Services::has('cache')); + $this->assertTrue(Services::has('timer')); + $this->assertTrue(Services::has('typography')); + + Services::resetForWorkerMode($config); + + $this->assertFalse(Services::has('cache')); + $this->assertTrue(Services::has('timer')); + $this->assertFalse(Services::has('typography')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testReconnectCacheForWorkerMode(): void + { + Services::cache(); + $this->assertTrue(Services::has('cache')); + + Services::reconnectCacheForWorkerMode(); + + $this->assertTrue(Services::has('cache')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testReconnectCacheForWorkerModeWithNoCache(): void + { + Services::resetSingle('cache'); + $this->assertFalse(Services::has('cache')); + + Services::reconnectCacheForWorkerMode(); + + $this->assertFalse(Services::has('cache')); + } } diff --git a/tests/system/Database/Live/PingTest.php b/tests/system/Database/Live/PingTest.php new file mode 100644 index 000000000000..4271f4f57a2e --- /dev/null +++ b/tests/system/Database/Live/PingTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class PingTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $migrate = false; + + public function testPingReturnsTrueWhenConnected(): void + { + $this->db->initialize(); + + $result = $this->db->ping(); + + $this->assertTrue($result); + } + + public function testPingReturnsFalseWhenNotConnected(): void + { + $this->db->close(); + + $this->setPrivateProperty($this->db, 'connID', false); + + $result = $this->db->ping(); + + $this->assertFalse($result); + } + + public function testPingAfterReconnect(): void + { + $this->db->close(); + $this->db->reconnect(); + + $result = $this->db->ping(); + + $this->assertTrue($result); + } + + public function testPingCanBeUsedToCheckConnectionBeforeQuery(): void + { + if ($this->db->ping()) { + $sql = $this->db->DBDriver === 'OCI8' ? 'SELECT 1 FROM DUAL' : 'SELECT 1'; + $result = $this->db->query($sql); + $this->assertNotFalse($result); + } else { + $this->fail('Connection should be alive'); + } + } +} diff --git a/tests/system/Database/Live/WorkerModeTest.php b/tests/system/Database/Live/WorkerModeTest.php new file mode 100644 index 000000000000..a8c77d756da7 --- /dev/null +++ b/tests/system/Database/Live/WorkerModeTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Config; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class WorkerModeTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected function tearDown(): void + { + parent::tearDown(); + + $this->setPrivateProperty(Config::class, 'instances', []); + } + + public function testCleanupForWorkerMode(): void + { + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + + $conn->transStart(); + $this->assertGreaterThan(0, $conn->transDepth); + + Config::cleanupForWorkerMode(); + + $this->assertSame(0, $conn->transDepth); + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } + + public function testReconnectForWorkerMode(): void + { + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + + Config::reconnectForWorkerMode(); + + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } +} diff --git a/tests/system/Events/EventsTest.php b/tests/system/Events/EventsTest.php index dc22ec3cd92b..5ec4ae679d3b 100644 --- a/tests/system/Events/EventsTest.php +++ b/tests/system/Events/EventsTest.php @@ -332,4 +332,18 @@ private function getEditableObject(): stdClass return clone $user; } + + public function testResetForWorkerMode(): void + { + Events::on('test', static fn (): null => null); + Events::trigger('test'); + + $performanceLog = Events::getPerformanceLogs(); + $this->assertNotEmpty($performanceLog); + + Events::cleanupForWorkerMode(); + + $performanceLog = Events::getPerformanceLogs(); + $this->assertEmpty($performanceLog); + } } diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 9244385a908e..2e7821446fe3 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use Redis; /** * @internal @@ -56,6 +57,13 @@ protected function getInstance($options = []): RedisHandler return new RedisHandler($sessionConfig, $this->userIpAddress); } + protected function tearDown(): void + { + parent::tearDown(); + + RedisHandler::resetPersistentConnections(); + } + public function testOpen(): void { $handler = $this->getInstance(); @@ -294,4 +302,61 @@ public static function provideSetSavePath(): iterable ], ]; } + + public function testConnectionReuse(): void + { + $handler1 = $this->getInstance(); + $handler1->open($this->sessionSavePath, $this->sessionName); + + $connection1 = $this->getPrivateProperty($handler1, 'redis'); + $this->assertInstanceOf(Redis::class, $connection1); + + $handler2 = $this->getInstance(); + $handler2->open($this->sessionSavePath, $this->sessionName); + + $connection2 = $this->getPrivateProperty($handler2, 'redis'); + + $this->assertSame($connection1, $connection2); + + $handler1->close(); + $handler2->close(); + } + + public function testDifferentConfigurationsGetDifferentConnections(): void + { + $handler1 = $this->getInstance(); + $handler1->open($this->sessionSavePath, $this->sessionName); + + $connection1 = $this->getPrivateProperty($handler1, 'redis'); + + $handler2 = $this->getInstance(['cookieName' => 'different_session']); + $handler2->open($this->sessionSavePath, 'different_session'); + + $connection2 = $this->getPrivateProperty($handler2, 'redis'); + + $this->assertNotSame($connection1, $connection2); + + $handler1->close(); + $handler2->close(); + } + + public function testResetPersistentConnections(): void + { + $handler1 = $this->getInstance(); + $handler1->open($this->sessionSavePath, $this->sessionName); + + $connection1 = $this->getPrivateProperty($handler1, 'redis'); + + RedisHandler::resetPersistentConnections(); + + $handler2 = $this->getInstance(); + $handler2->open($this->sessionSavePath, $this->sessionName); + + $connection2 = $this->getPrivateProperty($handler2, 'redis'); + + $this->assertNotSame($connection1, $connection2); + + $handler1->close(); + $handler2->close(); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 400f181f3244..562c7f5a4da9 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -15,6 +15,7 @@ Highlights ********** - Update minimal PHP requirement to ``8.2``. +- Added experimental **FrankenPHP Worker Mode** support for improved performance. ******** BREAKING @@ -288,21 +289,27 @@ Libraries - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. -- **Session:** Added ``persistent`` config item to redis handler. +- **Session:** Added ``persistent`` config item to ``RedisHandler``. +- **Session:** Added connection persistence for Redis and Memcached session handlers via ``PersistsConnection`` trait, improving performance in worker mode by reusing connections across requests. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` - **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. -- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. - **Toolbar:** Fixed an issue where the Debug Toolbar was incorrectly injected into responses generated by third-party libraries (e.g., Dompdf) that use native PHP headers instead of the framework's Response object. +- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. Commands ======== +- Added ``php spark worker:install`` command to generate FrankenPHP worker mode files (Caddyfile and worker entry point). +- Added ``php spark worker:uninstall`` command to remove worker mode files. + Testing ======= Database ======== +- **BaseConnection:** Added ``ping()`` method to check if the database connection is still alive. OCI8 uses ``SELECT 1 FROM DUAL``, other drivers use ``SELECT 1``. +- **BaseConnection:** The ``reconnect()`` method now uses ``ping()`` to check if the connection is alive before reconnecting. Previously, some drivers would always close and reconnect unconditionally, and OCI8's ``reconnect()`` did nothing. - **Exception Logging:** All DB drivers now log database exceptions uniformly. Previously, each driver had its own log format. Query Builder @@ -344,10 +351,17 @@ Message Changes Changes ******* +- **Boot:** Added ``Boot::bootWorker()`` method for one-time worker initialization. +- **CodeIgniter:** Added ``CodeIgniter::resetForWorkerMode()`` method to reset request-specific state between worker requests. +- **Config:** Added ``app/Config/WorkerMode.php`` for worker mode configuration (persistent services, garbage collection). - **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s T`` to follow the recommended format in RFC 7231. +- **DatabaseConfig:** Added ``Config::reconnectForWorkerMode()`` and ``Config::cleanupForWorkerMode()`` methods for database connection management in worker mode. +- **Events:** Added ``Events::cleanupForWorkerMode()`` method. - **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. +- **Services:** Added ``Services::resetForWorkerMode()`` and ``Services::reconnectCacheForWorkerMode()`` methods. - **Toolbar:** Added ``$disableOnHeaders`` property to **app/Config/Toolbar.php**. +- **Toolbar:** Added ``Toolbar::reset()`` method to reset debug toolbar collectors. ************ Deprecations diff --git a/user_guide_src/source/concepts/autoloader.rst b/user_guide_src/source/concepts/autoloader.rst index 6391148323c8..519b07342d76 100644 --- a/user_guide_src/source/concepts/autoloader.rst +++ b/user_guide_src/source/concepts/autoloader.rst @@ -212,3 +212,5 @@ Or you can enable it with the ``spark optimize`` command. .. note:: This property cannot be overridden by :ref:`environment variables `. + +.. warning:: Do not use this option when running the app in the :doc:`Worker Mode `. diff --git a/user_guide_src/source/concepts/factories.rst b/user_guide_src/source/concepts/factories.rst index aaf7df459c6d..f715fbd3ad0c 100644 --- a/user_guide_src/source/concepts/factories.rst +++ b/user_guide_src/source/concepts/factories.rst @@ -340,28 +340,4 @@ Or you can enable this with the ``spark optimize`` command. This property cannot be overridden by :ref:`environment variables `. -.. note:: - In v4.4.x, uncomment the following code in **public/index.php**:: - - --- a/public/index.php - +++ b/public/index.php - @@ -49,8 +49,8 @@ if (! defined('ENVIRONMENT')) { - } - - // Load Config Cache - -// $factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); - -// $factoriesCache->load('config'); - +$factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); - +$factoriesCache->load('config'); - // ^^^ Uncomment these lines if you want to use Config Caching. - - /* - @@ -79,7 +79,7 @@ $app->setContext($context); - $app->run(); - - // Save Config Cache - -// $factoriesCache->save('config'); - +$factoriesCache->save('config'); - // ^^^ Uncomment this line if you want to use Config Caching. - - // Exits the application, setting the exit code for CLI-based applications +.. warning:: Do not use this option when running the app in the :doc:`Worker Mode `. diff --git a/user_guide_src/source/database/connecting.rst b/user_guide_src/source/database/connecting.rst index d30fe155295b..65588dca4187 100644 --- a/user_guide_src/source/database/connecting.rst +++ b/user_guide_src/source/database/connecting.rst @@ -94,12 +94,12 @@ Reconnecting / Keeping the Connection Alive If the database server's idle timeout is exceeded while you're doing some heavy PHP lifting (processing an image, for instance), you should -consider pinging the server by using the ``reconnect()`` method before -sending further queries, which can gracefully keep the connection alive -or re-establish it. +consider calling the ``reconnect()`` method before sending further queries, +which can gracefully keep the connection alive or re-establish it. -.. important:: If you are using MySQLi database driver, the ``reconnect()`` method - does not ping the server but it closes the connection then connects again. +The ``reconnect()`` method pings the server to check if the connection is still +alive. If the connection has been lost, it will close and re-establish the +connection. .. literalinclude:: connecting/007.php :lines: 2- diff --git a/user_guide_src/source/installation/deployment.rst b/user_guide_src/source/installation/deployment.rst index 0eb206ee9d04..e6dc702b60a4 100644 --- a/user_guide_src/source/installation/deployment.rst +++ b/user_guide_src/source/installation/deployment.rst @@ -28,6 +28,8 @@ The ``spark optimize`` command performs the following optimizations: - Enabling `Config Caching`_ - Enabling `FileLocator Caching`_ +.. warning:: Do not use ``spark optimize`` when running the app in the :doc:`worker_mode`. + Composer Optimization ===================== diff --git a/user_guide_src/source/installation/index.rst b/user_guide_src/source/installation/index.rst index f81b61a589d4..ae1e432f6b89 100644 --- a/user_guide_src/source/installation/index.rst +++ b/user_guide_src/source/installation/index.rst @@ -28,6 +28,7 @@ repository. installing_composer installing_manual running + worker_mode troubleshooting deployment ../changelogs/index diff --git a/user_guide_src/source/installation/running.rst b/user_guide_src/source/installation/running.rst index 0fbc6fdc9c11..0faa0f3f81d8 100644 --- a/user_guide_src/source/installation/running.rst +++ b/user_guide_src/source/installation/running.rst @@ -158,6 +158,39 @@ The local development server can be customized with three command line options: php spark serve --php /usr/bin/php7.6.5.4 +*************************** +Worker Mode with FrankenPHP +*************************** + +.. versionadded:: 4.7.0 + +.. important:: Worker Mode is currently **experimental**. The only officially supported + worker implementation is **FrankenPHP**, which is backed by the PHP Foundation. + +FrankenPHP is a modern PHP application server that supports Worker Mode, allowing +your application to handle multiple requests within the same PHP process for +improved performance. + +Quick Start +=========== + +1. Install FrankenPHP using `static binaries `_ + +2. Generate worker mode files: + +.. code-block:: console + + php spark worker:install + +3. Start the server: + +.. code-block:: console + + frankenphp run + +For detailed configuration, performance tuning, and important considerations +about state management, see :doc:`worker_mode`. + ******************* Hosting with Apache ******************* diff --git a/user_guide_src/source/installation/worker_mode.rst b/user_guide_src/source/installation/worker_mode.rst new file mode 100644 index 000000000000..4dea3d1aad32 --- /dev/null +++ b/user_guide_src/source/installation/worker_mode.rst @@ -0,0 +1,307 @@ +########### +Worker Mode +########### + +.. contents:: + :local: + :depth: 2 + +.. versionadded:: 4.7.0 + +.. important:: Worker Mode is currently **experimental**. The only officially supported + worker implementation is **FrankenPHP**, which is backed by the PHP Foundation. + +************ +Introduction +************ + +What is Worker Mode? +==================== + +Worker Mode is a performance optimization feature that allows CodeIgniter to handle multiple +HTTP requests within the same PHP process, instead of starting a new process for each request +like traditional PHP-FPM does. + +Traditional PHP vs Worker Mode +------------------------------ + +**Traditional PHP (PHP-FPM)** + +In traditional PHP, each HTTP request goes through this cycle: + +1. Web server receives request and spawns a new PHP process +2. PHP loads and parses all required files +3. Framework bootstraps: autoloader, configuration, services, routes +4. Database connections are established +5. Request is processed and response is sent +6. All connections are closed +7. PHP process terminates, freeing all memory + +This "shared nothing" architecture is simple and safe, but inefficient for high-traffic +applications because steps 2-4 repeat for every single request. + +**Worker Mode** + +In Worker Mode, the lifecycle changes dramatically: + +1. Worker process starts and performs one-time initialization: + + - Loads and parses all required files (cached in OPcache) + - Bootstraps framework: autoloader, configuration, services, routes + - Establishes database and cache connections + +2. For each incoming request: + + - Reuses existing connections and cached resources + - Resets only request-specific state (superglobals, request/response objects) + - Processes request and sends response + - Cleans up request-specific data + +3. Worker continues running, ready for the next request + +This approach eliminates redundant initialization work and connection overhead, +resulting in **2-3x performance improvements** for typical database-driven applications. + +How CodeIgniter Manages State +============================= + +The key challenge in Worker Mode is preventing state from leaking between requests. +CodeIgniter handles this through several mechanisms: + +**Services Reset** + Most services are destroyed and recreated for each request. Only services listed + in ``$persistentServices`` survive between requests. + +**Factories Reset** + All Factories (Models, Config instances) are reset between requests, + ensuring fresh instances without stale data. + +**Superglobals Isolation** + Request data (``$_GET``, ``$_POST``, ``$_SERVER``, etc.) is properly isolated + per request through the Superglobals service. + +**Connection Persistence** + Database and cache connections are validated at request start and reused if healthy. + Unhealthy connections are automatically re-established. + +*************** +Getting Started +*************** + +Installation +============ + +1. Install FrankenPHP following the `official documentation `_ + + You can use a static binary or Docker. Here, we will assume we use a static binary, + which can be `downloaded directly `_. + +2. Install the Worker Mode template files using the spark command: + + .. code-block:: console + + php spark worker:install + + This command creates two files: + + - **Caddyfile** - FrankenPHP configuration file with worker mode enabled + - **public/frankenphp-worker.php** - Worker entry point that handles the request loop + +3. Configure your worker settings in **app/Config/WorkerMode.php** if needed. + The defaults are recommended for most applications. + +Running the Worker +================== + +Start FrankenPHP using the generated Caddyfile: + +.. code-block:: console + + frankenphp run + +The server will start with worker mode enabled, handling requests through the worker entry point. + +To run in the background (daemon mode): + +.. code-block:: console + + frankenphp start + +Uninstalling +============ + +To remove the Worker Mode template files: + +.. code-block:: console + + php spark worker:uninstall + +This removes the **Caddyfile** and **public/frankenphp-worker.php** files. + +********************** +Performance Benchmarks +********************** + +Worker Mode typically provides **2-3x performance improvements** for database-driven +applications. The actual gains depend on your application's characteristics: + +===================== ===================================================================== +Scenario Expected Improvement +===================== ===================================================================== +**Simple endpoints** Applications returning minimal JSON responses show modest + improvements (10-30%), as there's little bootstrap overhead + to eliminate. +**Database queries** Endpoints performing database queries typically see **2-3x** + improvements from connection reuse and reduced initialization + overhead. +**Complex bootstrap** Applications with many services, routes, or configuration see + the largest gains, as this overhead is eliminated entirely. +===================== ===================================================================== + +Worker Count Considerations +=========================== + +Total throughput scales with worker count. Match worker count to your typical concurrency +patterns and available CPU cores. Too few workers limit concurrency; too many waste memory. + +First Request Latency +===================== + +The initial request to each worker is slower due to bootstrap and connection establishment. +Subsequent requests benefit from cached resources and persistent connections. + +************* +Configuration +************* + +All worker mode configuration is managed through the **app/Config/WorkerMode.php** file. + +Configuration Options +===================== + +=========================== ======= ====================================================================== +Option Type Description +=========================== ======= ====================================================================== +**$persistentServices** array Services that persist across requests and are not reset. Services not + in this list are destroyed after each request to prevent state leakage. + Default: ``['autoloader', 'locator', 'exceptions', 'commands', + 'codeigniter', 'superglobals', 'routes', 'cache']`` +**$forceGarbageCollection** bool Whether to force garbage collection after each request. + ``true`` (default, recommended): Prevents memory leaks. + ``false``: Relies on PHP's automatic garbage collection. +=========================== ======= ====================================================================== + +Persistent Services +=================== + +The ``$persistentServices`` array controls which services survive between requests. +The default configuration includes: + +================ ========================================================================== +Service Purpose +================ ========================================================================== +``autoloader`` PSR-4 autoloading configuration. Safe to persist as class maps don't change. +``locator`` File locator for finding framework files. Caches file paths for performance. +``exceptions`` Exception handler. Stateless, safe to reuse. +``commands`` CLI commands registry. Only used during worker startup. +``codeigniter`` Main application instance. Orchestrates the request/response cycle. +``superglobals`` Superglobals wrapper. Properly isolated per request internally. +``routes`` Router configuration. Route definitions don't change between requests. +``cache`` Cache service. Maintains connections to cache backends (Redis, Memcached). +================ ========================================================================== + +.. warning:: Adding services to ``$persistentServices`` without understanding their + state management can cause data leakage between requests. Only persist services + that are truly stateless or manage their own request isolation. + +********************** +Optimize Configuration +********************** + +The ``app/Config/Optimize.php`` configuration options (Config Caching and File Locator Caching) +should **NOT** be used with Worker Mode. + +These optimizations were designed for traditional PHP where each request starts fresh. In Worker +Mode, the persistent process already provides these benefits naturally, and enabling them can +cause issues with stale data. + +.. warning:: Do not enable ``$configCacheEnabled`` or ``$locatorCacheEnabled`` in + **app/Config/Optimize.php** when using Worker Mode. + +OPcache Settings +================ + +Worker Mode benefits significantly from OPcache. Ensure it's enabled and properly configured: + +.. code-block:: ini + + opcache.enable=1 + opcache.memory_consumption=256 + opcache.max_accelerated_files=20000 + opcache.validate_timestamps=0 ; In production only + +************************ +Important Considerations +************************ + +State Management +================ + +Since the PHP process persists across requests, careful attention to state management is essential: + +- **Avoid static properties** for request-specific data, as these persist between requests +- **Be cautious with singletons** that might retain request state +- **Clean up resources** like file handles and database cursors after each request +- **Don't store user data** in class properties that persist across requests + +Registrars +========== + +Config registrars (``Config/Registrar.php`` files) work correctly in Worker Mode. +Registrar files are discovered once when the worker starts, and registrar logic is applied +every time a Config class is instantiated. Each request gets a fresh Config instance with +registrars applied. + +Memory Management +================= + +Monitor memory usage: + +.. code-block:: console + + ps aux | grep frankenphp + +If memory grows continuously: + +- Verify garbage collection is enabled (``$forceGarbageCollection = true``) +- Ensure resources are properly closed after use +- Unset large objects when no longer needed +- Check for circular references that prevent garbage collection + +Debugging +========= + +Worker Mode can make debugging more challenging since the process persists across requests: + +- **Restart workers after code changes** - workers don't auto-reload modified files +- **Check logs** in **writable/logs/** for connection and state-related issues +- **Use logging liberally** to trace request flow across the persistent process + +Session and Cache Handlers +========================== + +**File-based handlers** may experience file locking contention between workers. +For production environments, consider using: + +- **Redis** or **Memcached** for sessions and cache +- These provide better concurrency and automatic connection persistence + +Database Connections +==================== + +Database connections are automatically managed: + +- Connections persist across requests for performance +- Connections are validated at the start of each request +- Failed connections are automatically re-established +- Uncommitted transactions are automatically rolled back with a warning logged From 7b4859274a3be5f11a524852b307be3d58a468aa Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 1 Feb 2026 17:57:30 +0800 Subject: [PATCH 77/84] refactor: cleanup `ContentSecurityPolicy` (#9904) --- app/Config/ContentSecurityPolicy.php | 10 +- system/HTTP/ContentSecurityPolicy.php | 351 +++++++------- .../system/HTTP/ContentSecurityPolicyTest.php | 450 +++++++++++------- utils/phpstan-baseline/empty.notAllowed.neon | 7 +- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 182 +------ 6 files changed, 450 insertions(+), 552 deletions(-) diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index 2ac41a70dadb..8096f6cff95e 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -38,12 +38,12 @@ class ContentSecurityPolicy extends BaseConfig public bool $upgradeInsecureRequests = false; // ------------------------------------------------------------------------- - // Sources allowed + // CSP DIRECTIVES SETTINGS // NOTE: once you set a policy to 'none', it cannot be further restricted // ------------------------------------------------------------------------- /** - * Will default to self if not overridden + * Will default to `'self'` if not overridden * * @var list|string|null */ @@ -160,17 +160,17 @@ class ContentSecurityPolicy extends BaseConfig public $sandbox; /** - * Nonce tag for style + * Nonce placeholder for style tags. */ public string $styleNonceTag = '{csp-style-nonce}'; /** - * Nonce tag for script + * Nonce placeholder for script tags. */ public string $scriptNonceTag = '{csp-script-nonce}'; /** - * Replace nonce tag automatically + * Replace nonce tag automatically? */ public bool $autoNonce = true; } diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index a6a2b26a71fc..db1db04e0fdf 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -28,12 +28,7 @@ */ class ContentSecurityPolicy { - /** - * CSP directives - * - * @var array [name => property] - */ - protected array $directives = [ + private const DIRECTIVES_ALLOWING_SOURCE_LISTS = [ 'base-uri' => 'baseURI', 'child-src' => 'childSrc', 'connect-src' => 'connectSrc', @@ -50,145 +45,162 @@ class ContentSecurityPolicy 'style-src' => 'styleSrc', 'manifest-src' => 'manifestSrc', 'sandbox' => 'sandbox', - 'report-uri' => 'reportURI', ]; /** - * Used for security enforcement + * Map of CSP directives to this class's properties. * - * @var array|string + * @var array + */ + protected array $directives = [ + ...self::DIRECTIVES_ALLOWING_SOURCE_LISTS, + 'report-uri' => 'reportURI', + ]; + + /** + * The `base-uri` directive restricts the URLs that can be used to specify the document base URL. + * + * @var array|string|null */ protected $baseURI = []; /** - * Used for security enforcement + * The `child-src` directive governs the creation of nested browsing contexts as well + * as Worker execution contexts. * - * @var array|string + * @var array|string */ protected $childSrc = []; /** - * Used for security enforcement + * The `connect-src` directive restricts which URLs the protected resource can load using script interfaces. * - * @var array + * @var array|string */ protected $connectSrc = []; /** - * Used for security enforcement + * The `default-src` directive sets a default source list for a number of directives. * - * @var array|string + * @var array|string|null */ protected $defaultSrc = []; /** - * Used for security enforcement + * The `font-src` directive restricts from where the protected resource can load fonts. * - * @var array|string + * @var array|string */ protected $fontSrc = []; /** - * Used for security enforcement + * The `form-action` directive restricts which URLs can be used as the action of HTML form elements. * - * @var array|string + * @var array|string */ protected $formAction = []; /** - * Used for security enforcement + * The `frame-ancestors` directive indicates whether the user agent should allow embedding + * the resource using a `frame`, `iframe`, `object`, `embed` or `applet` element, + * or equivalent functionality in non-HTML resources. * - * @var array|string + * @var array|string */ protected $frameAncestors = []; /** - * Used for security enforcement + * The `frame-src` directive restricts the URLs which may be loaded into child navigables. * - * @var array|string + * @var array|string */ protected $frameSrc = []; /** - * Used for security enforcement + * The `img-src` directive restricts from where the protected resource can load images. * - * @var array|string + * @var array|string */ protected $imageSrc = []; /** - * Used for security enforcement + * The `media-src` directive restricts from where the protected resource can load video, + * audio, and associated text tracks. * - * @var array|string + * @var array|string */ protected $mediaSrc = []; /** - * Used for security enforcement + * The `object-src` directive restricts from where the protected resource can load plugins. * - * @var array|string + * @var array|string */ protected $objectSrc = []; /** - * Used for security enforcement + * The `plugin-types` directive restricts the set of plugins that can be invoked by the + * protected resource by limiting the types of resources that can be embedded. * - * @var array|string + * @var array|string */ protected $pluginTypes = []; /** - * Used for security enforcement + * The `script-src` directive restricts which scripts the protected resource can execute. * - * @var array|string + * @var array|string */ protected $scriptSrc = []; /** - * Used for security enforcement + * The `style-src` directive restricts which styles the user may applies to the protected resource. * - * @var array|string + * @var array|string */ protected $styleSrc = []; /** - * Used for security enforcement + * The `sandbox` directive specifies an HTML sandbox policy that the user agent applies to the protected resource. * - * @var array|string + * @var array|string */ - protected $manifestSrc = []; + protected $sandbox = []; /** - * Used for security enforcement + * The `report-uri` directive specifies a URL to which the user agent sends reports about policy violation. * - * @var array|string + * @var string|null */ - protected $sandbox = []; + protected $reportURI; + + // -------------------------------------------------------------- + // CSP Level 3 Directives + // -------------------------------------------------------------- /** - * A set of endpoints to which csp violation reports will be sent when - * particular behaviors are prevented. + * The `manifest-src` directive restricts the URLs from which application manifests may be loaded. * - * @var string|null + * @var array|string */ - protected $reportURI; + protected $manifestSrc = []; /** - * Used for security enforcement + * Instructs user agents to rewrite URL schemes by changing HTTP to HTTPS. * * @var bool */ protected $upgradeInsecureRequests = false; /** - * Used for security enforcement + * Set to `true` to make all directives report-only instead of enforced. * * @var bool */ protected $reportOnly = false; /** - * Used for security enforcement + * Set of valid source keywords. * * @var list */ @@ -200,60 +212,59 @@ class ContentSecurityPolicy ]; /** - * Used for security enforcement + * Set of nonces generated. * - * @var array + * @var list */ protected $nonces = []; /** - * Nonce for style + * Nonce for style tags. * - * @var string + * @var string|null */ protected $styleNonce; /** - * Nonce for script + * Nonce for script tags. * - * @var string + * @var string|null */ protected $scriptNonce; /** - * Nonce tag for style + * Nonce placeholder for style tags. * * @var string */ protected $styleNonceTag = '{csp-style-nonce}'; /** - * Nonce tag for script + * Nonce placeholder for script tags. * * @var string */ protected $scriptNonceTag = '{csp-script-nonce}'; /** - * Replace nonce tag automatically + * Replace nonce tags automatically? * * @var bool */ protected $autoNonce = true; /** - * An array of header info since we have - * to build ourselves before passing to Response. + * An array of header info since we have to build + * ourselves before passing to a Response object. * - * @var array + * @var array */ protected $tempHeaders = []; /** - * An array of header info to build - * that should only be reported. + * An array of header info to build that should only be reported. * - * @var array + * @var array */ protected $reportOnlyHeaders = []; @@ -265,27 +276,38 @@ class ContentSecurityPolicy protected $CSPEnabled = false; /** - * Constructor. - * * Stores our default values from the Config file. */ public function __construct(ContentSecurityPolicyConfig $config) { - $appConfig = config(App::class); - $this->CSPEnabled = $appConfig->CSPEnabled; + $this->CSPEnabled = config(App::class)->CSPEnabled; foreach (get_object_vars($config) as $setting => $value) { - if (property_exists($this, $setting)) { - $this->{$setting} = $value; + if (! property_exists($this, $setting)) { + continue; + } + + if ( + in_array($setting, self::DIRECTIVES_ALLOWING_SOURCE_LISTS, true) + && is_array($value) + && array_is_list($value) + ) { + // Config sets these directives as `list|string` + // but we need them as `array` internally. + $this->{$setting} = array_combine($value, array_fill(0, count($value), $this->reportOnly)); + + continue; } + + $this->{$setting} = $value; } if (! is_array($this->styleSrc)) { - $this->styleSrc = [$this->styleSrc]; + $this->styleSrc = [$this->styleSrc => $this->reportOnly]; } if (! is_array($this->scriptSrc)) { - $this->scriptSrc = [$this->scriptSrc]; + $this->scriptSrc = [$this->scriptSrc => $this->reportOnly]; } } @@ -304,7 +326,7 @@ public function getStyleNonce(): string { if ($this->styleNonce === null) { $this->styleNonce = bin2hex(random_bytes(12)); - $this->styleSrc[] = 'nonce-' . $this->styleNonce; + $this->addStyleSrc('nonce-' . $this->styleNonce); } return $this->styleNonce; @@ -317,7 +339,7 @@ public function getScriptNonce(): string { if ($this->scriptNonce === null) { $this->scriptNonce = bin2hex(random_bytes(12)); - $this->scriptSrc[] = 'nonce-' . $this->scriptNonce; + $this->addScriptSrc('nonce-' . $this->scriptNonce); } return $this->scriptNonce; @@ -356,13 +378,13 @@ public function reportOnly(bool $value = true) } /** - * Adds a new baseURI value. Can be either a URI class or a simple string. + * Adds a new value to the `base-uri` directive. * - * baseURI restricts the URLs that can appear in a page's element. + * `base-uri` restricts the URLs that can appear in a page's element. * * @see http://www.w3.org/TR/CSP/#directive-base-uri * - * @param array|string $uri + * @param list|string $uri * * @return $this */ @@ -374,16 +396,15 @@ public function addBaseURI($uri, ?bool $explicitReporting = null) } /** - * Adds a new valid endpoint for a form's action. Can be either - * a URI class or a simple string. + * Adds a new value to the `child-src` directive. * - * child-src lists the URLs for workers and embedded frame contents. + * `child-src` lists the URLs for workers and embedded frame contents. * For example: child-src https://youtube.com would enable embedding * videos from YouTube but not from other origins. * * @see http://www.w3.org/TR/CSP/#directive-child-src * - * @param array|string $uri + * @param list|string $uri * * @return $this */ @@ -395,15 +416,14 @@ public function addChildSrc($uri, ?bool $explicitReporting = null) } /** - * Adds a new valid endpoint for a form's action. Can be either - * a URI class or a simple string. + * Adds a new value to the `connect-src` directive. * - * connect-src limits the origins to which you can connect + * `connect-src` limits the origins to which you can connect * (via XHR, WebSockets, and EventSource). * * @see http://www.w3.org/TR/CSP/#directive-connect-src * - * @param array|string $uri + * @param list|string $uri * * @return $this */ @@ -415,15 +435,14 @@ public function addConnectSrc($uri, ?bool $explicitReporting = null) } /** - * Adds a new valid endpoint for a form's action. Can be either - * a URI class or a simple string. + * Adds a new value to the `default-src` directive. * - * default_src is the URI that is used for many of the settings when + * `default-src` is the URI that is used for many of the settings when * no other source has been set. * * @see http://www.w3.org/TR/CSP/#directive-default-src * - * @param array|string $uri + * @param list|string $uri * * @return $this */ @@ -435,14 +454,13 @@ public function setDefaultSrc($uri, ?bool $explicitReporting = null) } /** - * Adds a new valid endpoint for a form's action. Can be either - * a URI class or a simple string. + * Adds a new value to the `font-src` directive. * - * font-src specifies the origins that can serve web fonts. + * `font-src` specifies the origins that can serve web fonts. * * @see http://www.w3.org/TR/CSP/#directive-font-src * - * @param array|string $uri + * @param list|string $uri * * @return $this */ @@ -454,12 +472,11 @@ public function addFontSrc($uri, ?bool $explicitReporting = null) } /** - * Adds a new valid endpoint for a form's action. Can be either - * a URI class or a simple string. + * Adds a new value to the `form-action` directive. * * @see http://www.w3.org/TR/CSP/#directive-form-action * - * @param array|string $uri + * @param list|string $uri * * @return $this */ @@ -471,12 +488,11 @@ public function addFormAction($uri, ?bool $explicitReporting = null) } /** - * Adds a new resource that should allow embedding the resource using - * ,