diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 43d520458bf0..95ef36cef421 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -163,11 +163,49 @@ jobs: -N -C \ -Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8" + - name: Resolve latest ImageMagick release tag + if: ${{ contains(inputs.extra-extensions, 'imagick') }} + id: imagemagick-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag=$(gh release view --repo vintagesucks/imagemagick-deb --json tagName --jq .tagName) + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + + - name: Cache ImageMagick debs + if: ${{ contains(inputs.extra-extensions, 'imagick') }} + id: imagemagick-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /tmp/imagemagick-debs + key: imagemagick-debs-noble-amd64-${{ steps.imagemagick-release.outputs.tag }} + - name: Install latest ImageMagick if: ${{ contains(inputs.extra-extensions, 'imagick') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGEMAGICK_TAG: ${{ steps.imagemagick-release.outputs.tag }} + IMAGEMAGICK_CACHE_HIT: ${{ steps.imagemagick-cache.outputs.cache-hit }} run: | + if [ "$IMAGEMAGICK_CACHE_HIT" != 'true' ]; then + echo "::group::Download ImageMagick debs ($IMAGEMAGICK_TAG)" + mkdir -p /tmp/imagemagick-debs + gh release download "$IMAGEMAGICK_TAG" \ + --repo vintagesucks/imagemagick-deb \ + --pattern '*noble_amd64*' \ + --dir /tmp/imagemagick-debs \ + --clobber + echo "::endgroup::" + else + echo "Using cached ImageMagick debs ($IMAGEMAGICK_TAG)" + fi + + echo "::group::Install ImageMagick 7 with AVIF rw+ support" sudo apt-get update - sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 + sudo apt-get install -y \ + ghostscript poppler-data libmagickwand-dev \ + /tmp/imagemagick-debs/*.deb + echo "::endgroup::" - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/test-file-permissions.yml b/.github/workflows/test-file-permissions.yml index 7116f4620869..01a4fe11ff0d 100644 --- a/.github/workflows/test-file-permissions.yml +++ b/.github/workflows/test-file-permissions.yml @@ -20,5 +20,10 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Detect unnecessary execution permissions run: php utils/check_permission_x.php diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index 1dd382cd069b..2b4f539eb74c 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -26,6 +26,11 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Setup Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index f64a9af22b0a..01cb8ec8c918 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -199,6 +199,16 @@ class ContentSecurityPolicy extends BaseConfig */ public $sandbox; + /** + * Enable nonce to style tags? + */ + public bool $enableStyleNonce = true; + + /** + * Enable nonce to script tags? + */ + public bool $enableScriptNonce = true; + /** * Nonce placeholder for style tags. */ diff --git a/app/Config/Database.php b/app/Config/Database.php index 060781ea18a3..45df7fe2e814 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -49,6 +49,7 @@ class Database extends Config 'datetime' => 'Y-m-d H:i:s', 'time' => 'H:i:s', ], + 'timezone' => false, ]; // /** @@ -98,6 +99,7 @@ class Database extends Config // 'datetime' => 'Y-m-d H:i:s', // 'time' => 'H:i:s', // ], + // 'timezone' => false, // ]; // /** @@ -106,22 +108,23 @@ class Database extends Config // * @var array // */ // public array $default = [ - // 'DSN' => '', - // 'hostname' => 'localhost', - // 'username' => 'root', - // 'password' => 'root', - // 'database' => 'ci4', - // 'schema' => 'dbo', - // 'DBDriver' => 'SQLSRV', - // 'DBPrefix' => '', - // 'pConnect' => false, - // 'DBDebug' => true, - // 'charset' => 'utf8', - // 'swapPre' => '', - // 'encrypt' => false, - // 'failover' => [], - // 'port' => 1433, - // 'dateFormat' => [ + // 'DSN' => '', + // 'hostname' => 'localhost', + // 'username' => 'root', + // 'password' => 'root', + // 'database' => 'ci4', + // 'schema' => 'dbo', + // 'DBDriver' => 'SQLSRV', + // 'DBPrefix' => '', + // 'pConnect' => false, + // 'DBDebug' => true, + // 'charset' => 'utf8', + // 'swapPre' => '', + // 'encrypt' => false, + // 'trustServerCertificate' => false, + // 'failover' => [], + // 'port' => 1433, + // 'dateFormat' => [ // 'date' => 'Y-m-d', // 'datetime' => 'Y-m-d H:i:s', // 'time' => 'H:i:s', @@ -155,6 +158,7 @@ class Database extends Config // 'datetime' => 'Y-m-d H:i:s', // 'time' => 'H:i:s', // ], + // 'timezone' => false, // ]; /** diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 9c83ae94e55c..a81c4f74c249 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -11,6 +11,7 @@ use CodeIgniter\Filters\InvalidChars; use CodeIgniter\Filters\PageCache; use CodeIgniter\Filters\PerformanceMetrics; +use CodeIgniter\Filters\RequestId; use CodeIgniter\Filters\SecureHeaders; class Filters extends BaseFilters @@ -34,6 +35,7 @@ class Filters extends BaseFilters 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, + 'requestid' => RequestId::class, ]; /** @@ -51,11 +53,13 @@ class Filters extends BaseFilters */ public array $required = [ 'before' => [ + // 'requestid', // Request ID for each request 'forcehttps', // Force Global Secure Requests 'pagecache', // Web Page Caching ], 'after' => [ 'pagecache', // Web Page Caching + // 'requestid', // Request ID for each request 'performance', // Performance Metrics 'toolbar', // Debug Toolbar ], @@ -93,7 +97,10 @@ class Filters extends BaseFilters * permits any HTTP method to access a controller. Accessing the controller * with a method you don't expect could bypass the filter. * - * @var array> + * **IMPORTANT:** HTTP methods are checked case-sensitively, so you should always + * use the uppercase form to avoid issues. + * + * @var array> */ public array $methods = []; diff --git a/app/Config/Logger.php b/app/Config/Logger.php index 799dc2c39080..b273cb093004 100644 --- a/app/Config/Logger.php +++ b/app/Config/Logger.php @@ -51,6 +51,57 @@ class Logger extends BaseConfig */ public string $dateFormat = 'Y-m-d H:i:s'; + /** + * -------------------------------------------------------------------------- + * Whether to log the global context + * -------------------------------------------------------------------------- + * + * You can enable/disable logging of global context data, which comes from the + * `CodeIgniter\Context\Context` class. This data is automatically included in + * logs, and can be set using the `set()` method of the Context class. This is + * useful for including additional information in your logs, such as user IDs, + * request IDs, etc. + * + * **NOTE:** This **DOES NOT** include any data that has been marked as hidden + * using the `setHidden()` method of the Context class. + */ + public bool $logGlobalContext = false; + + /** + * -------------------------------------------------------------------------- + * Whether to log per-call context data + * -------------------------------------------------------------------------- + * + * When enabled, context keys not used as placeholders in the message are + * passed to handlers as structured data. Per PSR-3, any ``Throwable`` instance + * in the ``exception`` key is automatically normalized to an array representation. + */ + public bool $logContext = false; + + /** + * -------------------------------------------------------------------------- + * Whether to include the stack trace for Throwables in context + * -------------------------------------------------------------------------- + * + * When enabled, the stack trace is included when normalizing a Throwable + * in the ``exception`` context key. Only relevant when $logContext is true. + */ + public bool $logContextTrace = false; + + /** + * -------------------------------------------------------------------------- + * Whether to keep context keys that were used as placeholders + * -------------------------------------------------------------------------- + * + * By default, context keys that were interpolated into the message as + * {placeholder} are stripped before passing context to handlers, since + * their values are already present in the message text. Set to true to + * keep them as structured data as well. + * + * Only relevant when $logContext is true. + */ + public bool $logContextUsedKeys = false; + /** * -------------------------------------------------------------------------- * Log Handlers diff --git a/app/Config/Mimes.php b/app/Config/Mimes.php index c2db7340cd7e..44f5a0a09d39 100644 --- a/app/Config/Mimes.php +++ b/app/Config/Mimes.php @@ -259,6 +259,7 @@ class Mimes 'image/x-png', ], 'webp' => 'image/webp', + 'avif' => 'image/avif', 'tif' => 'image/tiff', 'tiff' => 'image/tiff', 'css' => [ @@ -481,6 +482,10 @@ class Mimes 'model/stl', 'application/octet-stream', ], + 'md' => [ + 'text/markdown', + 'text/plain', + ], ]; /** diff --git a/app/Config/Publisher.php b/app/Config/Publisher.php index bf03be1db7e0..0cb6769972fc 100644 --- a/app/Config/Publisher.php +++ b/app/Config/Publisher.php @@ -23,6 +23,6 @@ class Publisher extends BasePublisher */ public $restrictions = [ ROOTPATH => '*', - FCPATH => '#\.(s?css|js|map|html?|xml|json|webmanifest|ttf|eot|woff2?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + FCPATH => '#\.(s?css|js|map|html?|xml|json|webmanifest|ttf|eot|woff2?|gif|jpe?g|tiff?|png|webp|avif|bmp|ico|svg)$#i', ]; } diff --git a/app/Config/Routing.php b/app/Config/Routing.php index 5aeee51d212d..47f0513187b7 100644 --- a/app/Config/Routing.php +++ b/app/Config/Routing.php @@ -146,4 +146,14 @@ class Routing extends BaseRouting * Default: false */ public bool $translateUriToCamelCase = true; + + /** + * Sample values for the ``spark routes`` command, keyed by placeholder + * name without the ``(:...)`` wrapper. Each value must match the + * placeholder's regular expression and overrides the built-in or + * auto-generated sample for that placeholder. + * + * @var array + */ + public array $placeholderSamples = []; } diff --git a/app/Config/Security.php b/app/Config/Security.php index 635f8b77b9b7..bc8c42b2de9c 100644 --- a/app/Config/Security.php +++ b/app/Config/Security.php @@ -17,6 +17,25 @@ class Security extends BaseConfig */ public string $csrfProtection = 'cookie'; + /** + * -------------------------------------------------------------------------- + * CSRF Fetch Metadata + * -------------------------------------------------------------------------- + * + * Whether to use Fetch Metadata request headers as a first-line CSRF check. + */ + public bool $csrfFetchMetadata = true; + + /** + * -------------------------------------------------------------------------- + * CSRF Fetch Metadata Reject Same Site + * -------------------------------------------------------------------------- + * + * Whether requests with the Sec-Fetch-Site: same-site header should be + * rejected instead of falling back to token verification. + */ + public bool $csrfFetchMetadataRejectSameSite = false; + /** * -------------------------------------------------------------------------- * CSRF Token Randomization diff --git a/app/Views/errors/html/debug.css b/app/Views/errors/html/debug.css index b8539a420221..2072418db1bd 100644 --- a/app/Views/errors/html/debug.css +++ b/app/Views/errors/html/debug.css @@ -46,9 +46,16 @@ p.lead { .header .container { padding: 1rem; } +.header-title { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; +} .header h1 { font-size: 2.5rem; font-weight: 500; + min-width: 0; } .header p { font-size: 1.2rem; @@ -65,8 +72,58 @@ p.lead { display: inline; } +.error-report { + flex: 0 0 auto; + margin-top: 0.35rem; +} + +.error-report-button { + align-items: center; + background: rgba(255,255,255,0.35); + border: 1px solid rgba(0,0,0,0.14); + border-radius: 5px; + box-sizing: border-box; + color: var(--main-text-color); + cursor: pointer; + display: inline-flex; + font-size: 0.82rem; + font-weight: 500; + gap: 0.35rem; + height: 1.875rem; + justify-content: center; + line-height: 1; + padding: 0 0.65rem; + transition: background-color 160ms ease-in-out, border-color 160ms ease-in-out, color 160ms ease-in-out, box-shadow 160ms ease-in-out; + white-space: nowrap; + width: 7.15rem; +} + +.error-report-button:hover { + background: rgba(255,255,255,0.6); + border-color: rgba(0,0,0,0.22); + color: var(--dark-text-color); + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.error-report-button:focus-visible { + border-color: rgba(0,0,0,0.35); + outline: 0; + box-shadow: 0 0 0 2px rgba(220,72,20,0.16); +} + +.error-report-button:active { + background: rgba(255,255,255,0.75); +} + +.error-report-icon { + flex: 0 0 auto; + height: 0.72rem; + width: 0.72rem; +} + .environment { background: var(--brand-primary-color); + box-sizing: border-box; color: var(--main-bg-color); text-align: center; padding: calc(4px + 0.2083vw); @@ -75,6 +132,26 @@ p.lead { position: fixed; } +@media (max-width: 40rem) { + .header { + margin-top: 0; + } + + .header p { + font-size: 1.1rem; + line-height: 1.45; + margin-top: 0.35rem; + overflow-wrap: anywhere; + } + + .environment { + font-size: 0.9rem; + line-height: 1.25; + padding: 0.45rem 0.75rem; + position: static; + } +} + .source { background: #343434; color: var(--light-text-color); diff --git a/app/Views/errors/html/debug.js b/app/Views/errors/html/debug.js index 99199cac872c..78b85a026ef7 100644 --- a/app/Views/errors/html/debug.js +++ b/app/Views/errors/html/debug.js @@ -114,3 +114,29 @@ function toggle(elem) return false; } + +function copyErrorReport(reportId, button) +{ + if (! navigator.clipboard || ! window.isSecureContext) + { + return false; + } + + var report = document.getElementById(reportId); + navigator.clipboard.writeText(report.value).then(function () { + showCopiedButton(button); + }); + + return false; +} + +function showCopiedButton(button) +{ + button.defaultHtml = button.defaultHtml || button.innerHTML; + button.innerHTML = 'Copied!'; + + window.clearTimeout(button.copyResetTimer); + button.copyResetTimer = window.setTimeout(function () { + button.innerHTML = button.defaultHtml; + }, 1500); +} diff --git a/app/Views/errors/html/error_exception.php b/app/Views/errors/html/error_exception.php index 2c4e00911365..eec30a3037dd 100644 --- a/app/Views/errors/html/error_exception.php +++ b/app/Views/errors/html/error_exception.php @@ -3,6 +3,7 @@ use CodeIgniter\CodeIgniter; $errorId = uniqid('error', true); +$copyableErrorReportId = $errorId . 'copyableErrorReport'; ?> @@ -30,7 +31,38 @@ Environment:
-

getCode() ? ' #' . $exception->getCode() : '') ?>

+
+

getCode() ? ' #' . $exception->getCode() : '') ?>

+
+ + +
+

getMessage())) ?> getMessage())) ?>" @@ -342,8 +374,9 @@ setStatusCode(http_response_code()); + $response = service('response'); + $responseStatusCode = http_response_code(); + $response->setStatusCode($responseStatusCode === false || $responseStatusCode === 0 ? $code : $responseStatusCode); ?>

diff --git a/app/Views/errors/html/error_report.php b/app/Views/errors/html/error_report.php new file mode 100644 index 000000000000..5927c4720d3a --- /dev/null +++ b/app/Views/errors/html/error_report.php @@ -0,0 +1,127 @@ +setStatusCode($code); + +$report = [ + '# ' . $reportTitle, + '', + '## Exception', + '', + '- Type: ' . $type, + '- Status Code: ' . $code, + '- Status: ' . $reportResponse->getReasonPhrase(), + $messageLines ? '- Message:' : '- Message: ' . $reportMessage, +]; + +if ($messageLines) { + $report[] = ''; + $report[] = '```text'; + $report[] = $reportMessage; + $report[] = '```'; +} + +$report[] = ''; +$report[] = '## Environment'; +$report[] = ''; +$report[] = '- PHP: ' . PHP_VERSION; +$report[] = '- CodeIgniter: ' . CodeIgniter::CI_VERSION; +$report[] = '- Environment: ' . ENVIRONMENT; +$report[] = '- SAPI: ' . PHP_SAPI; +$report[] = '- Time: ' . date('Y-m-d H:i:s e'); +$report[] = '- Memory Usage: ' . number_format(memory_get_usage(true) / 1024 / 1024, 2) . ' MB'; + +$reportRequest = service('request'); + +if ($reportRequest instanceof IncomingRequest) { + $reportPath = '/' . ltrim($reportRequest->getPath(), '/'); + $reportUri = $reportRequest->getUri(); + $reportUrl = $reportPath; + + if ($reportUri->getHost() !== '') { + $reportUrl = URI::createURIString( + $reportUri->getScheme(), + $reportUri->getHost() . ($reportUri->getPort() === null ? '' : ':' . $reportUri->getPort()), + $reportPath, + ); + } + + $report[] = ''; + $report[] = '## Request'; + $report[] = ''; + $report[] = '- Method: ' . $reportRequest->getMethod(); + $report[] = '- Path: ' . $reportPath; + $report[] = '- URL: ' . $reportUrl; + $report[] = '- User Agent: ' . $reportRequest->getUserAgent()->getAgentString(); +} + +$report[] = ''; +$report[] = '## Source'; +$report[] = ''; +$report[] = '`' . clean_path($file) . ':' . $line . '`'; + +if (is_file($file) && is_readable($file)) { + $sourceLines = file($file, FILE_IGNORE_NEW_LINES); + + if ($sourceLines !== false) { + $startLine = max($line - 5, 1); + $endLine = min($line + 5, count($sourceLines)); + + $report[] = ''; + $report[] = '```php'; + + for ($sourceLine = $startLine; $sourceLine <= $endLine; $sourceLine++) { + $report[] = sprintf( + '%s%4d %s', + $sourceLine === $line ? '>' : ' ', + $sourceLine, + $sourceLines[$sourceLine - 1], + ); + } + + $report[] = '```'; + } +} + +$previousException = $exception->getPrevious(); + +if ($previousException instanceof Throwable) { + $report[] = ''; + $report[] = '## Previous Exceptions'; + + while ($previousException instanceof Throwable) { + $report[] = '* ' . $previousException::class . ' - ' . $previousException->getMessage(); + $report[] = ' ' . clean_path($previousException->getFile()) . ':' . $previousException->getLine(); + + $previousException = $previousException->getPrevious(); + } +} + +if ($trace !== []) { + $report[] = ''; + $report[] = '## Stack Trace'; + $report[] = ''; + $report[] = '```text'; + + foreach (array_slice($trace, 0, 50) as $reportIndex => $reportRow) { + $reportLocation = isset($reportRow['file'], $reportRow['line']) + ? clean_path($reportRow['file']) . ':' . $reportRow['line'] + : '{PHP internal code}'; + $reportCall = ($reportRow['class'] ?? '') . ($reportRow['type'] ?? '') . ($reportRow['function'] ?? ''); + + $report[] = $reportIndex . ' ' . $reportLocation . ($reportCall === '' ? '' : ' ' . $reportCall . '()'); + } + + $report[] = '```'; +} + +echo esc(implode("\n", $report)) . "\n"; diff --git a/contributing/internals.md b/contributing/internals.md index 124d19209ac8..35a8dd4d444a 100644 --- a/contributing/internals.md +++ b/contributing/internals.md @@ -44,6 +44,31 @@ afraid to use it when it's needed and can help things. - Start simple, refactor as necessary to achieve clean separation of code, but don't overdo it. +## Deprecations + +Deprecations happen when code no longer fits its purpose or is superseded by better solutions. +In such cases, it's important that deprecations are documented clearly to guide developers during +their upgrade process. + +- Add `@deprecated` tags in the doc block of deprecated functions, methods, classes, etc. Include + the version number where the deprecation was introduced, a description of why it's deprecated, + and recommended replacement(s), if any. If there are multiple alternative approaches, list all of them. + +- Do not add `@deprecated` tags to a function/method if you only mean to deprecate its parameter(s). + This will cause the whole function/method to be marked as deprecated by IDEs. Instead, trigger + a deprecation inside the function/method body if those parameters are passed. Ensure to include + the version number since when the deprecation occurred. Optionally, but encouraged, add a `@todo` + comment so that it can be found later on. + +- User-facing deprecation warnings should also be triggered via the framework's deprecation handling + mechanism (e.g., `@trigger_error()` or error log entries) to alert end users. + +- Do not add new tests for deprecated code paths. Instead, use tools and static analysis to ensure + that no code within the framework or official packages is using the deprecated functionality. + +- Document all deprecations in the changelog file for that release, under a "Deprecations" + section, so users are informed when upgrading. + ## Testing Any new packages submitted to the framework must be accompanied by unit diff --git a/rector.php b/rector.php index dcf59d665e7f..ea36c3927621 100644 --- a/rector.php +++ b/rector.php @@ -34,7 +34,6 @@ use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector; -use Rector\PHPUnit\CodeQuality\Rector\FuncCall\AssertFuncCallToPHPUnitAssertRector; use Rector\PHPUnit\CodeQuality\Rector\StmtsAwareInterface\DeclareStrictTypesTestsRector; use Rector\PostRector\Rector\UnusedImportRemovingPostRector; use Rector\Privatization\Rector\Class_\FinalizeTestCaseClassRector; @@ -87,7 +86,7 @@ __DIR__ . '/system/ThirdParty', __DIR__ . '/tests/system/Config/fixtures', __DIR__ . '/tests/system/Filters/fixtures', - __DIR__ . '/tests/_support/Commands/Foobar.php', + __DIR__ . '/tests/_support/Commands/Legacy/Foobar.php', __DIR__ . '/tests/_support/View', __DIR__ . '/tests/system/View/Views', @@ -122,17 +121,24 @@ __DIR__ . '/tests/system/Debug/ExceptionsTest.php', ], + DeclareStrictTypesTestsRector::class => [ + __DIR__ . '/tests/system/Debug/ExceptionsTest.php', + ], + // use mt_rand instead of random_int on purpose on non-cryptographically random RandomFunctionRector::class, - // PHP 8.0 features but cause breaking changes + // CPP of untyped properties may break code of extended classes ClassPropertyAssignToConstructorPromotionRector::class => [ __DIR__ . '/system/Database/BaseResult.php', __DIR__ . '/system/Database/RawSql.php', + __DIR__ . '/system/Database/Exceptions/DatabaseException.php', __DIR__ . '/system/Debug/BaseExceptionHandler.php', + __DIR__ . '/system/Debug/Exceptions.php', __DIR__ . '/system/Filters/Filters.php', __DIR__ . '/system/HTTP/CURLRequest.php', __DIR__ . '/system/HTTP/DownloadResponse.php', + __DIR__ . '/system/HTTP/IncomingRequest.php', __DIR__ . '/system/Security/Security.php', __DIR__ . '/system/Session/Session.php', ], @@ -156,18 +162,9 @@ // possibly isset() on purpose, on updated Config classes property across versions IssetOnPropertyObjectToPropertyExistsRector::class, - AssertFuncCallToPHPUnitAssertRector::class => [ - // use $this inside static closure - __DIR__ . '/tests/system/AutoReview/FrameworkCodeTest.php', - ], - // some tests extended by other tests FinalizeTestCaseClassRector::class, - DeclareStrictTypesTestsRector::class => [ - __DIR__ . '/tests/system/Debug/ExceptionsTest.php', - ], - RemoveNullArgOnNullDefaultParamRector::class => [ // skip form query usage, easier to read __DIR__ . '/system/Model.php', diff --git a/structarmed.php b/structarmed.php index e1027f578ce2..7665bd34afd3 100644 --- a/structarmed.php +++ b/structarmed.php @@ -15,6 +15,7 @@ use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\Header; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SSEResponse; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\DataCaster\DataCaster; use CodeIgniter\Entity\Cast\CastInterface; @@ -64,11 +65,13 @@ ->layerPattern('Filters', '/^CodeIgniter\\\\Filters\\\\Filter.*$/') ->layerPattern('Format', '/^CodeIgniter\\\\Format\\\\.*$/') ->layerPattern('Honeypot', '/^CodeIgniter\\\\.*Honeypot.*$/') + ->layerPattern('Input', '/^CodeIgniter\\\\Input\\\\.*$/') ->layerPattern('URI', '/^CodeIgniter\\\\HTTP\\\\URI$/') ->layerPattern('HTTP', '/^CodeIgniter\\\\HTTP\\\\.*$/', '/(Exception|URI)/') ->layerPattern('I18n', '/^CodeIgniter\\\\I18n\\\\.*$/') ->layerPattern('Images', '/^CodeIgniter\\\\Images\\\\.*$/') ->layerPattern('Language', '/^CodeIgniter\\\\Language\\\\.*$/') + ->layerPattern('Lock', '/^CodeIgniter\\\\Lock\\\\.*$/') ->layerPattern('Log', '/^CodeIgniter\\\\Log\\\\.*$/') ->layerPattern('Model', '/^CodeIgniter\\\\.*Model$/') ->layerPattern('Modules', '/^CodeIgniter\\\\Modules\\\\.*$/') @@ -95,8 +98,10 @@ 'Files' => ['I18n'], 'Filters' => ['HTTP'], 'Honeypot' => ['Filters', 'HTTP'], - 'HTTP' => ['Cookie', 'Files', 'I18n', 'Security', 'URI'], + 'HTTP' => ['Cookie', 'Files', 'I18n', 'Input', 'Security', 'URI'], + 'Input' => ['I18n'], 'Images' => ['Files', 'I18n'], + 'Lock' => ['Cache'], 'Model' => ['Database', 'DataCaster', 'DataConverter', 'Entity', 'I18n', 'Pager', 'Validation'], 'Pager' => ['URI', 'View'], 'Publisher' => ['Files', 'URI'], @@ -106,7 +111,7 @@ 'Security' => ['Cookie', 'HTTP', 'I18n', 'Session'], 'Session' => ['Cookie', 'Database', 'HTTP', 'I18n'], 'Throttle' => ['Cache', 'I18n'], - 'Validation' => ['Database', 'HTTP'], + 'Validation' => ['Database', 'HTTP', 'I18n', 'Input'], 'View' => ['Cache'], ]) ->skipPathsForRuleset(['*test*']) @@ -149,4 +154,5 @@ ->skipClassViolation(Response::class, [PagerInterface::class]) ->skipClassViolation(RedirectResponse::class, [PagerInterface::class]) ->skipClassViolation(DownloadResponse::class, [PagerInterface::class]) + ->skipClassViolation(SSEResponse::class, [PagerInterface::class]) ->skipClassViolation(Validation::class, [RendererInterface::class]); diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php index 4badb8267139..1496301ca58d 100644 --- a/system/API/BaseTransformer.php +++ b/system/API/BaseTransformer.php @@ -25,6 +25,11 @@ * - 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. + * Since v4.8.0, per-type sparse fieldsets are also supported via the + * bracketed form (e.g., ?fields[posts]=id,slug). A transformer reads the + * fieldset that matches its declared $resourceType, so a nested + * PostTransformer with $resourceType = 'posts' applies ?fields[posts]=... + * while the root resource is unaffected. * - include: Comma-separated list of related resources to include * (e.g., ?include=posts,comments) * This looks for methods named `include{Resource}()` on the transformer, @@ -69,6 +74,11 @@ abstract class BaseTransformer implements TransformerInterface */ private ?array $includes = null; + /** + * Resource type used to resolve per-type sparse fieldsets. + */ + protected ?string $resourceType = null; + protected mixed $resource = null; public function __construct( @@ -82,16 +92,65 @@ public function __construct( $this->request = $request ?? request(); if ($explicitRequest || self::$depth === 0) { - $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; + $this->fields = $this->resolveFields(true); + $this->includes = $this->resolveIncludes(); + } elseif ($this->resourceType !== null) { + $this->fields = $this->resolveFields(false); + } + } + + /** + * Resolves the requested field list for this transformer from the request. + * + * Supports both the flat `?fields=a,b` form and the per-type sparse + * fieldset form `?fields[]=a,b`. The flat form is only honored when + * $allowFlat is true (i.e. for the root transformer); a type-specific + * fieldset is matched against this transformer's $resourceType at any + * nesting level. + * + * @return list|null + */ + private function resolveFields(bool $allowFlat): ?array + { + $fields = $this->request->getGet('fields'); + + // Sparse fieldsets: ?fields[posts]=id,slug -> ['posts' => 'id,slug'] + if (is_array($fields)) { + $scoped = ($this->resourceType !== null && is_string($fields[$this->resourceType] ?? null)) + ? $fields[$this->resourceType] + : null; + + return $scoped !== null ? $this->splitList($scoped) : null; + } + + // Flat fieldset: ?fields=id,slug (applies to the root only) + if ($allowFlat && is_string($fields)) { + return $this->splitList($fields); } + + return null; + } + + /** + * Resolves the requested include list from the request's `include` param. + * + * @return list|null + */ + private function resolveIncludes(): ?array + { + $includes = $this->request->getGet('include'); + + return is_string($includes) ? $this->splitList($includes) : $includes; + } + + /** + * Splits a comma-separated query value into a list of trimmed strings. + * + * @return list + */ + private function splitList(string $value): array + { + return array_map(trim(...), explode(',', $value)); } /** diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php index 4666149f27e9..fd304fe2b0fb 100644 --- a/system/Autoloader/Autoloader.php +++ b/system/Autoloader/Autoloader.php @@ -345,51 +345,6 @@ protected function includeFile(string $file) return false; } - /** - * Check file path. - * - * Checks special characters that are illegal in filenames on certain - * operating systems and special characters requiring special escaping - * to manipulate at the command line. Replaces spaces and consecutive - * dashes with a single dash. Trim period, dash and underscore from beginning - * and end of filename. - * - * @return string The sanitized filename - * - * @deprecated No longer used. See https://github.com/codeigniter4/CodeIgniter4/issues/7055 - */ - public function sanitizeFilename(string $filename): string - { - // Only allow characters deemed safe for POSIX portable filenames. - // Plus the forward slash for directory separators since this might be a path. - // http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_278 - // Modified to allow backslash and colons for on Windows machines. - $result = preg_match_all('/[^0-9\p{L}\s\/\-_.:\\\\]/u', $filename, $matches); - - if ($result > 0) { - $chars = implode('', $matches[0]); - - throw new InvalidArgumentException( - 'The file path contains special characters "' . $chars - . '" that are not allowed: "' . $filename . '"', - ); - } - if ($result === false) { - $message = preg_last_error_msg(); - - throw new RuntimeException($message . '. filename: "' . $filename . '"'); - } - - // Clean up our filename edges. - $cleanFilename = trim($filename, '.-_'); - - if ($filename !== $cleanFilename) { - throw new InvalidArgumentException('The characters ".-_" are not allowed in filename edges: "' . $filename . '"'); - } - - return $cleanFilename; - } - /** * @param array{only?: list, exclude?: list} $composerPackages */ @@ -467,42 +422,6 @@ private function loadComposerNamespaces(ClassLoader $composer, array $composerPa $this->addNamespace($newPaths); } - /** - * Locates autoload information from Composer, if available. - * - * @deprecated No longer used. - * - * @return void - */ - protected function discoverComposerNamespaces() - { - if (! is_file($this->composerPath)) { - return; - } - - /** @var ClassLoader $composer */ - $composer = include $this->composerPath; - $paths = $composer->getPrefixesPsr4(); - $classes = $composer->getClassMap(); - - unset($composer); - - // Get rid of CodeIgniter so we don't have duplicates - if (isset($paths['CodeIgniter\\'])) { - unset($paths['CodeIgniter\\']); - } - - $newPaths = []; - - foreach ($paths as $key => $value) { - // Composer stores namespaces with trailing slash. We don't. - $newPaths[rtrim($key, '\\ ')] = $value; - } - - $this->prefixes = array_merge($this->prefixes, $newPaths); - $this->classmap = array_merge($this->classmap, $classes); - } - /** * Loads helpers */ @@ -562,8 +481,11 @@ private function configureKint(): void } $csp = service('csp'); - if ($csp->enabled()) { - RichRenderer::$js_nonce = $csp->getScriptNonce(); + if ($csp->scriptNonceEnabled()) { + RichRenderer::$js_nonce = $csp->getScriptNonce(); + } + + if ($csp->styleNonceEnabled()) { RichRenderer::$css_nonce = $csp->getStyleNonce(); } diff --git a/system/BaseModel.php b/system/BaseModel.php index 6f52c02a48c6..2a826b9dd235 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -18,6 +18,7 @@ use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\Query; use CodeIgniter\Database\RawSql; use CodeIgniter\DataCaster\Cast\CastInterface; @@ -140,6 +141,12 @@ abstract class BaseModel */ protected $protectFields = true; + /** + * Whether Model should throw instead of silently discarding + * fields that are not in $allowedFields. + */ + protected bool $throwOnDisallowedFields = false; + /** * An array of field names that are allowed * to be set by the user in inserts/updates. @@ -586,6 +593,22 @@ abstract public function countAllResults(bool $reset = true, bool $test = false) */ abstract public function chunk(int $size, Closure $userFunc); + /** + * Loops over records in batches, allowing you to operate on each chunk at a time. + * This method works only with DB calls. + * + * This method calls the `$userFunc` with the chunk, instead of a single record as in `chunk()`. + * This allows you to operate on multiple records at once, which can be more efficient for certain operations. + * + * @param Closure(list>|list): mixed $userFunc + * + * @return void + * + * @throws DataException + * @throws InvalidArgumentException if $size is not a positive integer + */ + abstract public function chunkRows(int $size, Closure $userFunc); + /** * Fetches the row of database. * @@ -853,6 +876,7 @@ protected function validateID(mixed $id, bool $allowArray = true): void * @return ($returnID is true ? false|int|string : bool) * * @throws ReflectionException + * @throws UniqueConstraintViolationException */ public function insert($row = null, bool $returnID = true) { @@ -972,32 +996,31 @@ public function insertBatch(?array $set = null, ?bool $escape = null, int $batch $cleanValidationRules = $this->cleanValidationRules; $this->cleanValidationRules = false; - if (is_array($set)) { - foreach ($set as &$row) { - $row = $this->transformDataToArray($row, 'insert'); - - // Validate every row. - if (! $this->skipValidation && ! $this->validate($row)) { - // Restore $cleanValidationRules - $this->cleanValidationRules = $cleanValidationRules; + try { + if (is_array($set)) { + foreach ($set as &$row) { + $row = $this->transformDataToArray($row, 'insert'); - return false; - } + // Validate every row. + if (! $this->skipValidation && ! $this->validate($row)) { + return false; + } - // Must be called first so we don't - // strip out created_at values. - $row = $this->doProtectFieldsForInsert($row); + // Must be called first so we don't + // strip out created_at values. + $row = $this->doProtectFieldsForInsert($row); - // Set created_at and updated_at with same time - $date = $this->setDate(); - $row = $this->setCreatedField($row, $date); - $row = $this->setUpdatedField($row, $date); + // Set created_at and updated_at with same time + $date = $this->setDate(); + $row = $this->setCreatedField($row, $date); + $row = $this->setUpdatedField($row, $date); + } } + } finally { + // Restore $cleanValidationRules + $this->cleanValidationRules = $cleanValidationRules; } - // Restore $cleanValidationRules - $this->cleanValidationRules = $cleanValidationRules; - $eventData = ['data' => $set]; if ($this->tempAllowCallbacks) { @@ -1049,7 +1072,7 @@ public function update($id = null, $row = null): bool // Must be called first, so we don't // strip out updated_at values. - $row = $this->doProtectFields($row); + $row = $this->doProtectFieldsForUpdate($row); // doProtectFields() can further remove elements from // $row, so we need to check for empty dataset again @@ -1139,6 +1162,7 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc // Must be called first so we don't // strip out updated_at values. + $this->ensureNoDisallowedFields($row, $index === null ? [] : [$index]); $row = $this->doProtectFields($row); // Restore updateIndex value in case it was wiped out @@ -1364,6 +1388,19 @@ public function protect(bool $protect = true) return $this; } + /** + * Sets whether or not disallowed fields should throw an exception + * instead of being discarded. + * + * @return $this + */ + public function throwOnDisallowedFields(bool $throw = true) + { + $this->throwOnDisallowedFields = $throw; + + return $this; + } + /** * Ensures that only the fields that are allowed to be updated are * in the data array. @@ -1396,6 +1433,35 @@ protected function doProtectFields(array $row): array return $row; } + /** + * Throws when configured to detect fields that would be discarded. + * + * @param row_array $row + * @param list $ignoredFields + * + * @throws DataException + */ + protected function ensureNoDisallowedFields(array $row, array $ignoredFields = []): void + { + if (! $this->throwOnDisallowedFields || ! $this->protectFields || $this->allowedFields === []) { + return; + } + + $disallowedFields = []; + + foreach (array_keys($row) as $key) { + if (in_array($key, $this->allowedFields, true) || in_array($key, $ignoredFields, true)) { + continue; + } + + $disallowedFields[] = $key; + } + + if ($disallowedFields !== []) { + throw DataException::forDisallowedFields(static::class, $disallowedFields); + } + } + /** * Ensures that only the fields that are allowed to be inserted are in * the data array. @@ -1411,6 +1477,27 @@ protected function doProtectFields(array $row): array */ protected function doProtectFieldsForInsert(array $row): array { + $this->ensureNoDisallowedFields($row); + + return $this->doProtectFields($row); + } + + /** + * Ensures that only the fields that are allowed to be updated are in + * the data array. + * + * @used-by update() to protect against mass assignment vulnerabilities. + * + * @param row_array $row + * + * @return row_array + * + * @throws DataException + */ + protected function doProtectFieldsForUpdate(array $row): array + { + $this->ensureNoDisallowedFields($row); + return $this->doProtectFields($row); } diff --git a/system/Boot.php b/system/Boot.php index a1b8c9695004..d506fd97b9f6 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -161,9 +161,8 @@ public static function bootSpark(Paths $paths): int static::autoloadHelpers(); static::initializeCodeIgniter(); - $console = static::initializeConsole(); - return static::runCommand($console); + return static::runCommand(static::initializeConsole()); } /** @@ -424,25 +423,11 @@ protected static function saveConfigCache(FactoriesCache $factoriesCache): void protected static function initializeConsole(): Console { - $console = new Console(); - - $args = $_SERVER['argv'] ?? []; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) - - // Show basic information before we do anything else. - if (is_int($suppress = array_search('--no-header', $args, true))) { - unset($args[$suppress]); - $suppress = true; - } - - $console->showHeader($suppress); - - return $console; + return new Console(); } protected static function runCommand(Console $console): int { - $exit = $console->run(); - - return is_int($exit) ? $exit : EXIT_SUCCESS; + return $console->initialize()->run(); } } diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php new file mode 100644 index 000000000000..011058598d99 --- /dev/null +++ b/system/CLI/AbstractCommand.php @@ -0,0 +1,1104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Exceptions\ArgumentCountMismatchException; +use CodeIgniter\CLI\Exceptions\CommandNotAvailableException; +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; +use CodeIgniter\CLI\Exceptions\OptionValueMismatchException; +use CodeIgniter\CLI\Exceptions\UnknownOptionException; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\HTTP\CLIRequest; +use Config\App; +use Config\Services; +use ReflectionClass; +use Throwable; + +/** + * Base class for all modern spark commands. + * + * Each command should extend this class and implement the `execute()` method. + */ +abstract class AbstractCommand +{ + private readonly string $name; + private readonly string $description; + private readonly string $group; + + /** + * @var list + */ + private readonly array $aliases; + + /** + * @var list + */ + private array $usages = []; + + /** + * @var array + */ + private array $argumentsDefinition = []; + + /** + * @var array + */ + private array $optionsDefinition = []; + + /** + * Map of shortcut character to the option name that declared it. + * + * @var array + */ + private array $shortcuts = []; + + /** + * Map of negated name to the option name it negates. + * + * @var array + */ + private array $negations = []; + + /** + * Cached list of required argument names, populated as definitions are added. + * + * @var list + */ + private array $requiredArguments = []; + + /** + * Cache of resolved `Command` attributes keyed by class name. + * + * @var array, Command> + */ + private static array $commandAttributeCache = []; + + /** + * The unbound arguments that can be passed to other commands when called via the `call()` method. + * + * @var list + */ + private array $unboundArguments = []; + + /** + * The unbound options that can be passed to child commands when called via the `call()` method. + * + * @var array|string|null> + */ + private array $unboundOptions = []; + + /** + * The validated arguments after binding, which will be passed to the `execute()` method. + * + * @var array|string> + */ + private array $validatedArguments = []; + + /** + * The validated options after binding, which will be passed to the `execute()` method. + * + * @var array|string|null> + */ + private array $validatedOptions = []; + + private ?string $lastOptionalArgument = null; + private ?string $lastArrayArgument = null; + + /** + * Interactive state pinned by `setInteractive()`. When boolean, it takes precedence over + * the per-run flag and TTY detection, and remains in effect across `run()` calls on + * the same instance. + */ + private ?bool $interactive = null; + + /** + * Per-run interactive state derived from `--no-interaction` / `-N` in the current `$options`. + */ + private ?bool $runtimeInteractive = null; + + /** + * @throws InvalidArgumentDefinitionException + * @throws InvalidOptionDefinitionException + * @throws LogicException + */ + public function __construct(private readonly Commands $commands) + { + $attribute = $this->getCommandAttribute(); + + $this->name = $attribute->name; + $this->description = $attribute->description; + $this->group = $attribute->group; + $this->aliases = $attribute->aliases; + + $this->configure(); + $this->provideDefaultOptions(); + + $this->createDefaultUsage(); + } + + public function getCommandRunner(): Commands + { + return $this->commands; + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getGroup(): string + { + return $this->group; + } + + /** + * @return list + */ + public function getAliases(): array + { + return $this->aliases; + } + + /** + * @return list + */ + public function getUsages(): array + { + return $this->usages; + } + + /** + * @return array + */ + public function getArgumentsDefinition(): array + { + return $this->argumentsDefinition; + } + + /** + * @return array + */ + public function getOptionsDefinition(): array + { + return $this->optionsDefinition; + } + + /** + * Returns the map of shortcut character to its owning option name. + * + * @return array + */ + public function getShortcuts(): array + { + return $this->shortcuts; + } + + /** + * Returns the map of negated name to the option name it negates. + * + * @return array + */ + public function getNegations(): array + { + return $this->negations; + } + + /** + * Appends a usage example aside from the default usage. + * + * @param non-empty-string $usage + */ + public function addUsage(string $usage): static + { + $this->usages[] = $usage; + + return $this; + } + + /** + * Adds an argument definition to the command. + * + * @throws InvalidArgumentDefinitionException + */ + public function addArgument(Argument $argument): static + { + $name = $argument->name; + + if ($this->hasArgument($name)) { + throw new InvalidArgumentDefinitionException(lang('Commands.duplicateArgument', [$name])); + } + + if ($this->lastArrayArgument !== null) { + throw new InvalidArgumentDefinitionException(lang('Commands.argumentAfterArrayArgument', [$name, $this->lastArrayArgument])); + } + + if ($argument->required && $this->lastOptionalArgument !== null) { + throw new InvalidArgumentDefinitionException(lang('Commands.requiredArgumentAfterOptionalArgument', [$name, $this->lastOptionalArgument])); + } + + if ($argument->isArray) { + $this->lastArrayArgument = $name; + } + + if ($argument->required) { + $this->requiredArguments[] = $name; + } else { + $this->lastOptionalArgument = $name; + } + + $this->argumentsDefinition[$name] = $argument; + + return $this; + } + + /** + * Adds an option definition to the command. + * + * @throws InvalidOptionDefinitionException + */ + public function addOption(Option $option): static + { + $name = $option->name; + + if ($this->hasOption($name)) { + throw new InvalidOptionDefinitionException(lang('Commands.duplicateOption', [$name])); + } + + if ($this->hasNegation($name)) { + throw new InvalidOptionDefinitionException(lang('Commands.optionClashesWithExistingNegation', [$name, $this->negations[$name]])); + } + + if ($option->shortcut !== null && $this->hasShortcut($option->shortcut)) { + throw new InvalidOptionDefinitionException(lang('Commands.duplicateShortcut', [$option->shortcut, $name, $this->shortcuts[$option->shortcut]])); + } + + if ($option->negation !== null && $this->hasOption($option->negation)) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionNegationExists', [$name])); + } + + if ($option->shortcut !== null) { + $this->shortcuts[$option->shortcut] = $name; + } + + if ($option->negation !== null) { + $this->negations[$option->negation] = $name; + } + + $this->optionsDefinition[$name] = $option; + + return $this; + } + + /** + * Renders the given `Throwable`. + * + * This is usually not needed to be called directly as the `Throwable` will be automatically rendered by the framework when it is thrown, + * but it can be useful to call this method directly when you want to render a `Throwable` that is caught by the command itself. + */ + public function renderThrowable(Throwable $e): void + { + // The exception handler picks a renderer based on the shared request + // instance. Ensure it is a CLIRequest; if the current shared request is + // not, swap it temporarily and restore it afterwards so other code paths + // do not observe our mutation. + $previous = Services::get('request'); + $swapped = false; + + if (! $previous instanceof CLIRequest) { + Services::createRequest(config(App::class), true); + $swapped = true; + } + + try { + service('exceptions')->exceptionHandler($e); + } finally { + if ($swapped) { + Services::override('request', $previous); + } + } + } + + /** + * Checks if the command has an argument defined with the given name. + */ + public function hasArgument(string $name): bool + { + return array_key_exists($name, $this->argumentsDefinition); + } + + /** + * Checks if the command has an option defined with the given name. + */ + public function hasOption(string $name): bool + { + return array_key_exists($name, $this->optionsDefinition); + } + + /** + * Checks if the command has a shortcut defined with the given name. + */ + public function hasShortcut(string $shortcut): bool + { + return array_key_exists($shortcut, $this->shortcuts); + } + + /** + * Checks if the command has a negation defined with the given name. + */ + public function hasNegation(string $name): bool + { + return array_key_exists($name, $this->negations); + } + + /** + * Reports whether the command is currently in interactive mode. + * + * Resolution order: + * 1. An explicit `setInteractive()` call wins. + * 2. Otherwise, the `--no-interaction` / `-N` flag from the current `run()` + * forces non-interactive. + * 3. Otherwise, the command is interactive when STDIN is a TTY. + * + * Non-CLI contexts (e.g., a controller invoking `command()`) don't expose + * `STDIN` at all; those always resolve as non-interactive. + */ + public function isInteractive(): bool + { + return $this->interactive + ?? $this->runtimeInteractive + ?? (defined('STDIN') && CLI::streamSupports('stream_isatty', \STDIN)); + } + + /** + * Pins the interactive state, overriding both the `--no-interaction` flag + * and STDIN TTY detection. + */ + public function setInteractive(bool $interactive): static + { + $this->interactive = $interactive; + + return $this; + } + + /** + * Runs the command. + * + * The lifecycle is: + * + * 1. Run `isAvailable()` to check if the command can be run in the current environment. + * 2. `initialize()` and `interact()` are handed the raw parsed input by reference, in that order. + * Both can mutate the tokens before the framework interprets them against the declared definitions. + * Note: the per-run interactive state is captured from `$options` before `initialize()` runs, so + * mutating `--no-interaction` from within `initialize()` will not affect this invocation. Use + * `setInteractive()` instead. + * 3. The post-hook input is snapshotted into `$unboundArguments` and `$unboundOptions` so the unbound + * accessors can report the tokens carried into binding (as opposed to what defaults resolved to). + * Any mutations performed in `initialize()` or `interact()` are therefore reflected in the snapshot. + * 4. `bind()` maps the raw tokens onto the declared arguments and options, applying defaults and + * coercing flag/negation values. + * 5. `validate()` rejects the bound result if it violates any of the declarations — missing required + * argument, unknown option, value/flag mismatches, and so on. + * 6. The bound-and-validated values are snapshotted into `$validatedArguments` / `$validatedOptions` + * and then passed to `execute()`, whose integer return is the command's exit code. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * + * @throws ArgumentCountMismatchException + * @throws CommandNotAvailableException + * @throws LogicException + * @throws OptionValueMismatchException + * @throws UnknownOptionException + */ + final public function run(array $arguments, array $options): int + { + if (! $this->isAvailable()) { + throw new CommandNotAvailableException(lang('Commands.notAvailable', [$this->name])); + } + + // Reset per-run interactive state from the current options. + $this->runtimeInteractive = $this->hasUnboundOption('no-interaction', $options) ? false : null; + + $this->initialize($arguments, $options); + + if ($this->isInteractive()) { + $this->interact($arguments, $options); + } + + $this->unboundArguments = $arguments; + $this->unboundOptions = $options; + + [$boundArguments, $boundOptions] = $this->bind($arguments, $options); + + $this->validate($boundArguments, $boundOptions); + + $this->validatedArguments = $boundArguments; + $this->validatedOptions = $boundOptions; + + return $this->execute($boundArguments, $boundOptions); + } + + /** + * Configures the command's arguments and options definitions. + * + * This method is called from the constructor of the command. + * + * @throws InvalidArgumentDefinitionException + * @throws InvalidOptionDefinitionException + */ + protected function configure(): void + { + } + + /** + * Checks whether this command is available to execute in the current environment. + */ + protected function isAvailable(): bool + { + return true; + } + + /** + * Initializes a command before the arguments and options are bound to their definitions. + * + * This is especially useful for commands that calls another commands, and needs to adjust the + * arguments and options before calling the other command. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + */ + protected function initialize(array &$arguments, array &$options): void + { + } + + /** + * Interacts with the user before executing the command. This should only be called if the command is being run + * in interactive mode, which is the default when running commands from the command line. + * + * This is especially useful for commands that needs to ask the user for confirmation before executing the command. + * It can also be used to ask the user for additional information that is not provided in the command line arguments and options. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + */ + protected function interact(array &$arguments, array &$options): void + { + } + + /** + * Executes the command with the bound arguments and options. + * + * Validation of the bound arguments and options is done before this method is called. + * As such, this method should not throw any exceptions. All exceptions should be rendered + * with a non-zero exit code. + * + * @param array|string> $arguments Bound arguments using the command's arguments definition. + * @param array|string|null> $options Bound options using the command's options definition. + */ + abstract protected function execute(array $arguments, array $options): int; + + /** + * Calls another command from the current command. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * @param bool|null $noInteractionOverride `null` (default) propagates the parent's non-interactive state; + * `true` forces the sub-command non-interactive by injecting + * `--no-interaction`; `false` removes any forwarded + * `--no-interaction` from `$options` so the sub-command + * resolves its own state (TTY detection may still downgrade it). + */ + protected function call(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null): int + { + return $this->commands->runCommand($command, $arguments, $this->resolveChildInteractiveState($options, $noInteractionOverride)); + } + + /** + * Like `call()`, but suppresses the sub-command's output. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * @param bool|null $noInteractionOverride See `call()` for the semantics. + */ + protected function callSilently(string $command, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true): int + { + $priorInputOutput = CLI::getInputOutput(); + $priorLastWrite = CLI::getLastWrite(); + + CLI::setInputOutput(new NullInputOutput()); + + try { + return $this->call($command, $arguments, $options, $noInteractionOverride); + } finally { + $priorInputOutput instanceof InputOutput + ? CLI::setInputOutput($priorInputOutput) + : CLI::resetInputOutput(); + CLI::setLastWrite($priorLastWrite); + } + } + + /** + * Gets the unbound arguments that can be passed to other commands when called via the `call()` method. + * + * @return list + */ + protected function getUnboundArguments(): array + { + return $this->unboundArguments; + } + + /** + * Gets the unbound argument at the given index. + * + * @throws LogicException + */ + protected function getUnboundArgument(int $index): string + { + if (! array_key_exists($index, $this->unboundArguments)) { + throw new LogicException(sprintf('Unbound argument at index "%d" does not exist.', $index)); + } + + return $this->unboundArguments[$index]; + } + + /** + * Gets the unbound options that can be passed to other commands when called via the `call()` method. + * + * @return array|string|null> + */ + protected function getUnboundOptions(): array + { + return $this->unboundOptions; + } + + /** + * Reads the raw (unbound) value of the option with the given declared name, resolving through its + * shortcut and negation. Returns `null` when the option was not provided under any of those aliases. + * + * Inside `interact()`, pass the `$options` parameter explicitly because the instance state is not yet + * populated at that point. Elsewhere, omit `$options` to read from the instance state. + * + * @param array|string|null>|null $options + * + * @return list|string|null + * + * @throws LogicException + */ + protected function getUnboundOption(string $name, ?array $options = null): array|string|null + { + $this->assertOptionIsDefined($name); + + $options ??= $this->unboundOptions; + + if (array_key_exists($name, $options)) { + return $options[$name]; + } + + $definition = $this->optionsDefinition[$name]; + + if ($definition->shortcut !== null && array_key_exists($definition->shortcut, $options)) { + return $options[$definition->shortcut]; + } + + if ($definition->negation !== null && array_key_exists($definition->negation, $options)) { + return $options[$definition->negation]; + } + + return null; + } + + /** + * Returns whether the option with the given declared name was provided in the raw (unbound) input — + * under its long name, shortcut, or negation. + * + * Inside `interact()`, pass the `$options` parameter explicitly; elsewhere omit it to read from + * instance state. + * + * @param array|string|null>|null $options + * + * @throws LogicException + */ + protected function hasUnboundOption(string $name, ?array $options = null): bool + { + $this->assertOptionIsDefined($name); + + $options ??= $this->unboundOptions; + + if (array_key_exists($name, $options)) { + return true; + } + + $definition = $this->optionsDefinition[$name]; + + if ($definition->shortcut !== null && array_key_exists($definition->shortcut, $options)) { + return true; + } + + return $definition->negation !== null && array_key_exists($definition->negation, $options); + } + + /** + * Gets the validated arguments after binding and validation. + * + * @return array|string> + */ + protected function getValidatedArguments(): array + { + return $this->validatedArguments; + } + + /** + * Gets the validated argument with the given name. + * + * @return list|string + * + * @throws LogicException + */ + protected function getValidatedArgument(string $name): array|string + { + if (! array_key_exists($name, $this->validatedArguments)) { + throw new LogicException(sprintf('Validated argument with name "%s" does not exist.', $name)); + } + + return $this->validatedArguments[$name]; + } + + /** + * Gets the validated options after binding and validation. + * + * @return array|string|null> + */ + protected function getValidatedOptions(): array + { + return $this->validatedOptions; + } + + /** + * Gets the validated option with the given name. + * + * @return bool|list|string|null + * + * @throws LogicException + */ + protected function getValidatedOption(string $name): array|bool|string|null + { + if (! array_key_exists($name, $this->validatedOptions)) { + throw new LogicException(sprintf('Validated option with name "%s" does not exist.', $name)); + } + + return $this->validatedOptions[$name]; + } + + /** + * Registers the options that the framework injects into every modern + * command. Every option registered here is load-bearing: + * + * - `--help` / `-h`: `Console` detects it and routes to the `help` command. + * - `--no-header`: `Console` strips it before rendering the banner. + * - `--no-interaction` / `-N`: `run()` folds it into the interactive state + * and `resolveChildInteractiveState()` reads it to drive the `call()` cascade. + * + * Subclasses that override this hook should re-register these options or + * accept that the corresponding framework features will be broken for + * the subclass. + */ + protected function provideDefaultOptions(): void + { + $this + ->addOption(new Option(name: 'help', shortcut: 'h', description: 'Display help for the given command.')) + ->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.')) + ->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.')); + } + + /** + * Reconciles the caller's explicit intent (`$noInteractionOverride`) with + * the parent command's own interactive state to produce the `$options` + * that `call()` should hand to the sub-command. + * + * - `null` (default) propagates the parent's non-interactive state by + * adding `--no-interaction` when the parent itself is non-interactive. + * If the caller already supplied `--no-interaction` under any of its + * aliases, their value is preserved. + * - `true` forces the sub-command non-interactive regardless of the + * parent, again deferring to a caller-supplied value if present. + * - `false` removes any `--no-interaction` from `$options` (whether + * caller-supplied or inherited) so the sub-command resolves its own + * state. TTY detection can still force non-interactive if STDIN is + * not a TTY. + * + * @param array|string|null> $options + * + * @return array|string|null> + */ + private function resolveChildInteractiveState(array $options, ?bool $noInteractionOverride): array + { + $this->assertOptionIsDefined('no-interaction'); + + if ($noInteractionOverride === false) { + $definition = $this->optionsDefinition['no-interaction']; + + $aliases = array_filter( + [$definition->name, $definition->shortcut, $definition->negation], + static fn (?string $alias): bool => $alias !== null, + ); + + foreach ($aliases as $alias) { + unset($options[$alias]); + } + + return $options; + } + + if ($this->hasUnboundOption('no-interaction', $options)) { + return $options; + } + + if ($noInteractionOverride === true || ! $this->isInteractive()) { + $options['no-interaction'] = null; // simulate --no-interaction being passed + } + + return $options; + } + + /** + * Binds the given raw arguments and options to the command's arguments and options + * definitions, and returns the bound arguments and options. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * + * @return array{ + * 0: array|string>, + * 1: array|string|null>, + * } + */ + private function bind(array $arguments, array $options): array + { + $boundArguments = []; + $boundOptions = []; + + // 1. Arguments are position-based, so we will bind them in the order they are defined + // as well as the order they are given in the command line. + foreach ($this->argumentsDefinition as $name => $definition) { + if ($definition->isArray) { + if ($arguments !== []) { + $boundArguments[$name] = array_values($arguments); + + $arguments = []; + } elseif (! $definition->required) { + $boundArguments[$name] = $definition->default; + } + } elseif ($definition->required) { + $argument = array_shift($arguments); + + if ($argument === null) { + continue; // Missing required argument. To skip for validation to catch later. + } + + $boundArguments[$name] = $argument; + } else { + $boundArguments[$name] = array_shift($arguments) ?? $definition->default; + } + } + + // 2. If there are still arguments left that are not defined, we will mark them as extraneous. + if ($arguments !== []) { + $boundArguments['extra_arguments'] = array_values($arguments); + } + + // 3. Options are name-based, so we will bind them by their names, shortcuts, and negations. + // Passed flag options will be set to `true`, otherwise, they will be set to `false`. + // Options that accept values will be set to the value passed or their default value if not passed. + // Negatable options will be set to `false` if the negation is passed. + foreach ($this->optionsDefinition as $name => $definition) { + if (array_key_exists($name, $options)) { + $boundOptions[$name] = $options[$name]; + unset($options[$name]); + } elseif ($definition->shortcut !== null && array_key_exists($definition->shortcut, $options)) { + $boundOptions[$name] = $options[$definition->shortcut]; + unset($options[$definition->shortcut]); + } elseif ($definition->negation !== null && array_key_exists($definition->negation, $options)) { + $boundOptions[$name] = $options[$definition->negation] ?? false; + + if (is_array($boundOptions[$name])) { + // Edge case: passing a negated option multiple times should normalize to false + $boundOptions[$name] = array_map(static fn (mixed $v): mixed => $v ?? false, $boundOptions[$name]); + } + + unset($options[$definition->negation]); + } else { + $boundOptions[$name] = $definition->default; + } + + if ($definition->isArray && ! is_array($boundOptions[$name])) { + $boundOptions[$name] = [$boundOptions[$name]]; + } elseif (! $definition->acceptsValue && ! $definition->negatable) { + $boundOptions[$name] ??= true; + } elseif ($definition->negatable) { + if (is_array($boundOptions[$name])) { + $boundOptions[$name] = array_map(static fn (mixed $v): mixed => $v ?? true, $boundOptions[$name]); + } else { + $boundOptions[$name] ??= true; + } + } + } + + // 4. If there are still options left that are not defined, we will mark them as extraneous. + foreach ($options as $name => $value) { + if ($this->hasShortcut($name)) { + // This scenario can happen when the command has an array option with a shortcut, + // and the shortcut is used alongside the long name, causing it to be not bound + // in the previous loop. The leftover shortcut value can itself be an array when + // the shortcut was passed multiple times, so merge arrays and append scalars. + $option = $this->shortcuts[$name]; + $values = is_array($value) ? $value : [$value]; + + if (array_key_exists($option, $boundOptions) && is_array($boundOptions[$option])) { + $boundOptions[$option] = [...$boundOptions[$option], ...$values]; + } else { + $boundOptions[$option] = [$boundOptions[$option], ...$values]; + } + + continue; + } + + if ($this->hasNegation($name)) { + // This scenario can happen when the command has a negatable option, + // and both the option and its negation are used, causing the negation + // to be not bound in the previous loop. The leftover negation value can + // be scalar (including a string when the negation was passed with a value) + // or an array — normalise to an array before mapping null → false. + $option = $this->negations[$name]; + $values = is_array($value) ? $value : [$value]; + $values = array_map(static fn (mixed $v): mixed => $v ?? false, $values); + + if (! is_array($boundOptions[$option])) { + $boundOptions[$option] = [$boundOptions[$option]]; + } + + $boundOptions[$option] = [...$boundOptions[$option], ...$values]; + + continue; + } + + $boundOptions['extra_options'] ??= []; + $boundOptions['extra_options'][$name] = $value; + } + + return [$boundArguments, $boundOptions]; + } + + /** + * Validates the bound arguments and options. + * + * @param array|string> $arguments Bound arguments using the command's arguments definition. + * @param array|string|null> $options Bound options using the command's options definition. + * + * @throws ArgumentCountMismatchException + * @throws LogicException + * @throws OptionValueMismatchException + * @throws UnknownOptionException + */ + private function validate(array $arguments, array $options): void + { + $this->validateArguments($arguments); + + foreach ($this->optionsDefinition as $name => $definition) { + $this->validateOption($name, $definition, $options[$name]); + } + + if (array_key_exists('extra_options', $options)) { + throw new UnknownOptionException(lang('Commands.unknownOptions', [ + count($options['extra_options']), + $this->name, + implode(', ', array_map( + static fn (string $key): string => strlen($key) === 1 ? sprintf('-%s', $key) : sprintf('--%s', $key), + array_keys($options['extra_options']), + )), + ])); + } + } + + /** + * @param array|string> $arguments + * + * @throws ArgumentCountMismatchException + */ + private function validateArguments(array $arguments): void + { + if ($this->argumentsDefinition === [] && $arguments !== []) { + assert(array_key_exists('extra_arguments', $arguments)); + + throw new ArgumentCountMismatchException(lang('Commands.noArgumentsExpected', [ + $this->name, + implode('", "', $arguments['extra_arguments']), + ])); + } + + if (array_diff($this->requiredArguments, array_keys($arguments)) !== []) { + throw new ArgumentCountMismatchException(lang('Commands.missingRequiredArguments', [ + $this->name, + count($this->requiredArguments), + implode(', ', $this->requiredArguments), + ])); + } + + if (array_key_exists('extra_arguments', $arguments)) { + throw new ArgumentCountMismatchException(lang('Commands.tooManyArguments', [ + $this->name, + count($arguments['extra_arguments']), + implode('", "', $arguments['extra_arguments']), + ])); + } + } + + /** + * @param bool|list|string|null $value + * + * @throws LogicException + * @throws OptionValueMismatchException + */ + private function validateOption(string $name, Option $definition, array|bool|string|null $value): void + { + if (! $definition->acceptsValue && ! $definition->negatable) { + if (is_array($value) && ! $definition->isArray) { + throw new LogicException(lang('Commands.flagOptionPassedMultipleTimes', [$name])); + } + + if (! is_bool($value)) { + throw new OptionValueMismatchException(lang('Commands.optionNotAcceptingValue', [$name])); + } + } + + if ($definition->acceptsValue && ! $definition->isArray && is_array($value)) { + throw new OptionValueMismatchException(lang('Commands.nonArrayOptionWithArrayValue', [$name])); + } + + if ($definition->requiresValue) { + $elements = is_array($value) ? $value : [$value]; + + foreach ($elements as $element) { + if (! is_string($element)) { + throw new OptionValueMismatchException(lang('Commands.optionRequiresValue', [$name])); + } + } + } + + if (! $definition->negatable || is_bool($value)) { + return; + } + + $this->validateNegatableOption($name, $definition, $value); + } + + /** + * @param list|string|null $value + * + * @throws LogicException + * @throws OptionValueMismatchException + */ + private function validateNegatableOption(string $name, Option $definition, array|string|null $value): void + { + if (! is_array($value)) { + if (array_key_exists($name, $this->unboundOptions)) { + throw new OptionValueMismatchException(lang('Commands.negatableOptionNoValue', [$name])); + } + + throw new OptionValueMismatchException(lang('Commands.negatedOptionNoValue', [$definition->negation])); + } + + // Both forms appearing together is the primary user mistake; flag it + // regardless of whether either form carried a value. + if ( + array_key_exists($name, $this->unboundOptions) + && array_key_exists($definition->negation, $this->unboundOptions) + ) { + throw new LogicException(lang('Commands.negatableOptionWithNegation', [$name, $definition->negation])); + } + + if (array_key_exists($name, $this->unboundOptions)) { + throw new OptionValueMismatchException(lang('Commands.negatableOptionPassedMultipleTimes', [$name])); + } + + throw new OptionValueMismatchException(lang('Commands.negatedOptionPassedMultipleTimes', [$definition->negation])); + } + + /** + * @throws LogicException + */ + private function assertOptionIsDefined(string $name): void + { + if (! $this->hasOption($name)) { + throw new LogicException(sprintf('Option "%s" is not defined on this command.', $name)); + } + } + + /** + * @throws LogicException + */ + private function getCommandAttribute(): Command + { + $class = static::class; + + if (array_key_exists($class, self::$commandAttributeCache)) { + return self::$commandAttributeCache[$class]; + } + + $attribute = (new ReflectionClass($this))->getAttributes(Command::class)[0] + ?? throw new LogicException(lang('Commands.missingCommandAttribute', [$class, Command::class])); + + self::$commandAttributeCache[$class] = $attribute->newInstance(); + + return self::$commandAttributeCache[$class]; + } + + /** + * Create a default usage based on docopt style. + * + * @see http://docopt.org/ + */ + private function createDefaultUsage(): void + { + $usage = [$this->name]; + + if ($this->optionsDefinition !== []) { + $usage[] = '[options]'; + } + + if ($this->argumentsDefinition !== []) { + $usage[] = '[--]'; + + foreach ($this->argumentsDefinition as $name => $definition) { + $usage[] = sprintf( + '%s<%s>%s%s', + $definition->required ? '' : '[', + $name, + $definition->isArray ? '...' : '', + $definition->required ? '' : ']', + ); + } + } + + array_unshift($this->usages, implode(' ', $usage)); + } +} diff --git a/system/CLI/Attributes/Command.php b/system/CLI/Attributes/Command.php new file mode 100644 index 000000000000..d22ff880c2ea --- /dev/null +++ b/system/CLI/Attributes/Command.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Attributes; + +use Attribute; +use CodeIgniter\Exceptions\LogicException; + +/** + * Attribute to mark a class as a CLI command. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class Command +{ + private const NAME_PATTERN = '/^[^\s\:]++(\:[^\s\:]++)*$/'; + + /** + * @var non-empty-string + */ + public string $name; + + /** + * @var list + */ + public array $aliases; + + /** + * @param list $aliases + * + * @throws LogicException + */ + public function __construct( + string $name, + public string $description = '', + public string $group = '', + array $aliases = [], + ) { + if ($name === '') { + throw new LogicException(lang('Commands.emptyCommandName')); + } + + if (preg_match(self::NAME_PATTERN, $name) !== 1) { + throw new LogicException(lang('Commands.invalidCommandName', [$name])); + } + + $this->name = $name; + + $seen = []; + + foreach ($aliases as $alias) { + if ($alias === '' || preg_match(self::NAME_PATTERN, $alias) !== 1) { + throw new LogicException(lang('Commands.invalidCommandAlias', [$alias])); + } + + if ($alias === $name) { + throw new LogicException(lang('Commands.commandAliasSameAsName', [$alias])); + } + + if (isset($seen[$alias])) { + throw new LogicException(lang('Commands.duplicateCommandAlias', [$alias])); + } + + $seen[$alias] = true; + } + + $this->aliases = array_values($aliases); + } +} diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php index 57df52d47f55..6f781443784e 100644 --- a/system/CLI/BaseCommand.php +++ b/system/CLI/BaseCommand.php @@ -101,7 +101,7 @@ public function __construct(LoggerInterface $logger, Commands $commands) * * @param array $params * - * @return int|void + * @return int|null */ abstract public function run(array $params); @@ -110,13 +110,13 @@ abstract public function run(array $params); * * @param array $params * - * @return int|void + * @return int|null * * @throws ReflectionException */ protected function call(string $command, array $params = []) { - return $this->commands->run($command, $params); + return $this->commands->runLegacy($command, $params); } /** @@ -193,26 +193,6 @@ public function setPad(string $item, int $max, int $extra = 2, int $indent = 0): return str_pad(str_repeat(' ', $indent) . $item, $max); } - /** - * Get pad for $key => $value array output - * - * @param array $array - * - * @deprecated Use setPad() instead. - * - * @codeCoverageIgnore - */ - public function getPad(array $array, int $pad): int - { - $max = 0; - - foreach (array_keys($array) as $key) { - $max = max($max, strlen($key)); - } - - return $max + $pad; - } - /** * Makes it simple to access our protected properties. * diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 3996f31ee349..45e9d8772e7c 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -32,25 +32,10 @@ */ class CLI { - /** - * Is the readline library on the system? - * - * @var bool - * - * @deprecated 4.4.2 Should be protected, and no longer used. - * @TODO Fix to camelCase in the next major version. - */ - public static $readline_support = false; - /** * The message displayed at prompts. - * - * @var string - * - * @deprecated 4.4.2 Should be protected. - * @TODO Fix to camelCase in the next major version. */ - public static $wait_msg = 'Press any key to continue...'; + protected static string $waitMsg = 'Press any key to continue...'; /** * Has the class already been initialized? @@ -112,7 +97,7 @@ class CLI protected static $segments = []; /** - * @var array + * @var array|string|null> */ protected static $options = []; @@ -159,11 +144,6 @@ class CLI public static function init() { if (is_cli()) { - // Readline is an extension for PHP that makes interactivity with PHP - // much more bash-like. - // http://www.php.net/manual/en/readline.installation.php - static::$readline_support = extension_loaded('readline'); - // clear segments & options to keep testing clean static::$segments = []; static::$options = []; @@ -171,12 +151,14 @@ public static function init() // Check our stream resource for color support static::$isColored = static::hasColorSupport(STDOUT); - static::parseCommandLine(); + $parser = new CommandLineParser(service('superglobals')->server('argv', [])); + + static::$segments = $parser->getArguments(); + static::$options = $parser->getOptions(); static::$initialized = true; } elseif (! defined('STDOUT')) { - // If the command is being called from a controller - // we need to define STDOUT ourselves + // If the command is being called from a controller we need to define STDOUT ourselves // For "! defined('STDOUT')" see: https://github.com/codeigniter4/CodeIgniter4/issues/7047 define('STDOUT', 'php://output'); // @codeCoverageIgnore } @@ -531,21 +513,11 @@ public static function wait(int $seconds, bool $countdown = false) } elseif ($seconds > 0) { sleep($seconds); } else { - static::write(static::$wait_msg); + static::write(static::$waitMsg); static::$io->input(); } } - /** - * if operating system === windows - * - * @deprecated 4.3.0 Use `is_windows()` instead - */ - public static function isWindows(): bool - { - return is_windows(); - } - /** * Enter a number of empty lines * @@ -684,7 +656,7 @@ public static function strlen(?string $string): int */ public static function streamSupports(string $function, $resource): bool { - if (ENVIRONMENT === 'testing') { + if (service('environment')->isTesting()) { // In the current setup of the tests we cannot fully check // if the stream supports the function since we are using // filtered streams. @@ -883,10 +855,14 @@ public static function wrap(?string $string = null, int $max = 0, int $padLeft = * Parses the command line it was called from and collects all * options and valid segments. * + * @deprecated 4.8.0 No longer used. + * * @return void */ protected static function parseCommandLine() { + @trigger_error(sprintf('The static method %s() is deprecated and no longer used.', __METHOD__), E_USER_DEPRECATED); + $args = $_SERVER['argv'] ?? []; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) array_shift($args); // scrap invoking program $optionValue = false; @@ -958,8 +934,11 @@ public static function getSegments(): array } /** - * Gets a single command-line option. Returns TRUE if the option - * exists, but doesn't have a value, and is simply acting as a flag. + * Gets the value of an individual option. + * + * * If the option was passed without a value, this will return `true`. + * * If the option was not passed at all, this will return `null`. + * * If the option was an array of values, this will return the last value passed for that option. * * @return string|true|null */ @@ -969,17 +948,34 @@ public static function getOption(string $name) return null; } - // If the option didn't have a value, simply return TRUE - // so they know it was set, otherwise return the actual value. - $val = static::$options[$name] ?? true; + $value = static::$options[$name] ?? true; + + if (! is_array($value)) { + return $value; + } + + return $value[count($value) - 1] ?? true; + } + + /** + * Gets the raw value of an individual option, which may be a string, + * a list of `string|null`, or `true` if the option was passed without a value. + * + * @return list|string|true|null + */ + public static function getRawOption(string $name): array|string|true|null + { + if (! array_key_exists($name, static::$options)) { + return null; + } - return $val; + return static::$options[$name] ?? true; } /** * Returns the raw array of options found. * - * @return array + * @return array|string|null> */ public static function getOptions(): array { @@ -999,27 +995,33 @@ public static function getOptionString(bool $useLongOpts = false, bool $trim = f return ''; } - $out = ''; + $out = []; - foreach (static::$options as $name => $value) { - if ($useLongOpts && mb_strlen($name) > 1) { - $out .= "--{$name} "; + $valueCallback = static function (?string $value, string $name) use (&$out): void { + if ($value === null) { + $out[] = $name; + } elseif (str_contains($value, ' ')) { + $out[] = sprintf('%s "%s"', $name, $value); } else { - $out .= "-{$name} "; + $out[] = sprintf('%s %s', $name, $value); } + }; - if ($value === null) { - continue; - } + foreach (static::$options as $name => $value) { + $name = $useLongOpts && mb_strlen($name) > 1 ? "--{$name}" : "-{$name}"; - if (str_contains($value, ' ')) { - $out .= "\"{$value}\" "; - } elseif ($value !== null) { - $out .= "{$value} "; + if (is_array($value)) { + foreach ($value as $val) { + $valueCallback($val, $name); + } + } else { + $valueCallback($value, $name); } } - return $trim ? trim($out) : $out; + $output = implode(' ', $out); + + return $trim ? $output : "{$output} "; } /** @@ -1164,8 +1166,30 @@ public static function resetLastWrite(): void } /** - * Testing purpose only - * + * @internal + */ + public static function getLastWrite(): ?string + { + return static::$lastWrite; + } + + /** + * @internal + */ + public static function setLastWrite(?string $value): void + { + static::$lastWrite = $value; + } + + /** + * @internal + */ + public static function getInputOutput(): ?InputOutput + { + return static::$io; + } + + /** * @internal */ public static function setInputOutput(InputOutput $io): void @@ -1174,8 +1198,6 @@ public static function setInputOutput(InputOutput $io): void } /** - * Testing purpose only - * * @internal */ public static function resetInputOutput(): void diff --git a/system/CLI/CommandLineParser.php b/system/CLI/CommandLineParser.php new file mode 100644 index 000000000000..69ed4d0de517 --- /dev/null +++ b/system/CLI/CommandLineParser.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\CLI; + +final class CommandLineParser +{ + /** + * @var list + */ + private array $arguments = []; + + /** + * @var array|string|null> + */ + private array $options = []; + + /** + * @var array|string|null> + */ + private array $tokens = []; + + /** + * @param list $tokens + */ + public function __construct(array $tokens) + { + $this->parseTokens($tokens); + } + + /** + * @return list + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * @return array|string|null> + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @return array|string|null> + */ + public function getTokens(): array + { + return $this->tokens; + } + + /** + * @param list $tokens + */ + private function parseTokens(array $tokens): void + { + array_shift($tokens); // Remove the application name + + $parseOptions = true; + $optionValue = false; + + foreach ($tokens as $index => $token) { + if ($token === '--' && $parseOptions) { + $parseOptions = false; + + continue; + } + + if (str_starts_with($token, '-') && $parseOptions) { + $name = ltrim($token, '-'); + $value = null; + + if (str_contains($name, '=')) { + [$name, $value] = explode('=', $name, 2); + } elseif (isset($tokens[$index + 1]) && ! str_starts_with($tokens[$index + 1], '-')) { + $value = $tokens[$index + 1]; + + $optionValue = true; + } + + if (array_key_exists($name, $this->options)) { + if (! is_array($this->options[$name])) { + $this->options[$name] = [$this->options[$name]]; + $this->tokens[$name] = [$this->tokens[$name]]; + } + + $this->options[$name][] = $value; + $this->tokens[$name][] = $value; + } else { + $this->options[$name] = $value; + $this->tokens[$name] = $value; + } + + continue; + } + + if (! str_starts_with($token, '-') && $optionValue) { + $optionValue = false; + + continue; + } + + $this->arguments[] = $token; + $this->tokens[] = $token; + } + } +} diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 8f623d509c0a..6997a9208ac0 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -14,35 +14,54 @@ namespace CodeIgniter\CLI; use CodeIgniter\Autoloader\FileLocatorInterface; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Exceptions\CommandNotFoundException; use CodeIgniter\Events\Events; +use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Log\Logger; +use ReflectionAttribute; use ReflectionClass; use ReflectionException; /** - * Core functionality for running, listing, etc commands. + * Command discovery and execution class. * - * @phpstan-type commands_list array, 'file': string, 'group': string,'description': string}> + * @phpstan-type legacy_commands array, file: string, group: string, description: string}> + * @phpstan-type modern_commands array, file: string, group: string, description: string, aliases: list}> */ class Commands { /** - * The found commands. - * - * @var commands_list + * @var legacy_commands */ protected $commands = []; /** - * Logger instance. - * * @var Logger */ protected $logger; /** - * Constructor + * Discovered modern commands keyed by command name. Kept `private` so + * subclasses do not mutate the registry directly; use {@see getModernCommands()}. * + * @var modern_commands + */ + private array $modernCommands = []; + + /** + * Maps an alias name to the canonical modern command name it resolves to. + * + * @var array + */ + private array $aliases = []; + + /** + * Guards {@see discoverCommands()} from re-scanning the filesystem on repeat calls. + */ + private bool $discovered = false; + + /** * @param Logger|null $logger */ public function __construct($logger = null) @@ -52,40 +71,166 @@ public function __construct($logger = null) } /** - * Runs a command given + * Runs a legacy command. + * + * @deprecated 4.8.0 Use {@see runLegacy()} instead. * * @param array $params * - * @return int Exit code + * @return int */ public function run(string $command, array $params) { - if (! $this->verifyCommand($command, $this->commands)) { + @trigger_error(sprintf( + 'Since v4.8.0, "%s()" is deprecated. Use "%s::runLegacy()" instead.', + __METHOD__, + self::class, + ), E_USER_DEPRECATED); + + return $this->runLegacy($command, $params); + } + + /** + * Runs a legacy command. + * + * @param array $params + */ + public function runLegacy(string $command, array $params): int + { + if (! $this->verifyCommand($command)) { return EXIT_ERROR; } - $className = $this->commands[$command]['class']; - $class = new $className($this->logger, $this); + Events::trigger('pre_command'); + + $exitCode = $this->getCommand($command, legacy: true)->run($params); + + Events::trigger('post_command'); + + if (! is_int($exitCode)) { + @trigger_error(sprintf( + 'Since v4.8.0, commands must return an integer exit code. Last command "%s" exited with %s. Defaulting to EXIT_SUCCESS.', + $command, + get_debug_type($exitCode), + ), E_USER_DEPRECATED); + $exitCode = EXIT_SUCCESS; // @codeCoverageIgnore + } + + return $exitCode; + } + + /** + * Runs a modern command. + * + * @param list $arguments + * @param array|string|null> $options + */ + public function runCommand(string $command, array $arguments, array $options): int + { + if (! $this->verifyCommand($command, legacy: false)) { + return EXIT_ERROR; + } Events::trigger('pre_command'); - $exit = $class->run($params); + $exitCode = $this->getCommand($command, legacy: false)->run($arguments, $options); Events::trigger('post_command'); - return $exit; + return $exitCode; } /** - * Provide access to the list of commands. + * Provide access to the list of legacy commands. * - * @return commands_list + * @return legacy_commands */ public function getCommands() { return $this->commands; } + /** + * Provide access to the list of modern commands. + * + * @return modern_commands + */ + public function getModernCommands(): array + { + return $this->modernCommands; + } + + /** + * Provide access to the alias map of modern commands. + * + * @return array Alias name mapped to its canonical command name. + */ + public function getCommandAliases(): array + { + return $this->aliases; + } + + /** + * Checks if a legacy command with the given name has been discovered. + */ + public function hasLegacyCommand(string $name): bool + { + return array_key_exists($name, $this->commands); + } + + /** + * Checks whether the given name resolves to a modern command, either as a + * command name or as one of its aliases. + * + * A name present in both registries signals a collision. Legacy wins at + * runtime. Callers can combine this with `hasLegacyCommand()` to detect + * that case. + */ + public function hasModernCommand(string $name): bool + { + return $this->resolveCommand($name) !== null; + } + + /** + * @return ($legacy is true ? BaseCommand : AbstractCommand) + * + * @throws CommandNotFoundException + */ + public function getCommand(string $command, bool $legacy = false): AbstractCommand|BaseCommand + { + if ($legacy && isset($this->commands[$command])) { + $className = $this->commands[$command]['class']; + + return new $className($this->logger, $this); + } + + if (! $legacy) { + $resolved = $this->resolveCommand($command); + + if ($resolved !== null) { + $className = $this->modernCommands[$resolved]['class']; + + return new $className($this); + } + } + + throw new CommandNotFoundException($command); + } + + /** + * Resolves a modern command name or alias to its canonical command name, + * or `null` when neither matches. The command name takes precedence so an + * alias can never shadow a real command. + */ + private function resolveCommand(string $name): ?string + { + if (isset($this->modernCommands[$name])) { + return $name; + } + + return $this->aliases[$name] ?? null; + } + /** * Discovers all commands in the framework and within user code, * and collects instances of them to work with. @@ -94,68 +239,106 @@ public function getCommands() */ public function discoverCommands() { - if ($this->commands !== []) { + if ($this->discovered) { return; } - /** @var FileLocatorInterface */ - $locator = service('locator'); - $files = $locator->listFiles('Commands/'); + $this->discovered = true; - if ($files === []) { - return; - } + /** @var FileLocatorInterface $locator */ + $locator = service('locator'); - foreach ($files as $file) { - /** @var class-string|false */ + foreach ($locator->listFiles('Commands/') as $file) { $className = $locator->findQualifiedNameFromPath($file); if ($className === false || ! class_exists($className)) { continue; } - try { - $class = new ReflectionClass($className); + $class = new ReflectionClass($className); - if (! $class->isInstantiable() || ! $class->isSubclassOf(BaseCommand::class)) { - continue; - } + if (! $class->isInstantiable()) { + continue; + } - $class = new $className($this->logger, $this); + if ($class->isSubclassOf(BaseCommand::class)) { + $this->registerLegacyCommand($class, $file); + } elseif ($class->isSubclassOf(AbstractCommand::class)) { + $this->registerModernCommand($class, $file); + } + } - if ($class->group !== null && ! isset($this->commands[$class->name])) { - $this->commands[$class->name] = [ - 'class' => $className, - 'file' => $file, - 'group' => $class->group, - 'description' => $class->description, - ]; + ksort($this->commands); + ksort($this->modernCommands); + + foreach (array_keys(array_intersect_key($this->commands, $this->modernCommands)) as $name) { + CLI::write( + CLI::wrap( + lang('Commands.duplicateCommandName', [ + $name, + $this->commands[$name]['class'], + $this->modernCommands[$name]['class'], + ]), + ), + 'yellow', + ); + } + + $this->registerAliases(); + } + + /** + * Builds the alias map from the discovered modern commands. Fails hard when + * an alias collides with an existing command name or another alias. + * + * @throws LogicException + */ + private function registerAliases(): void + { + foreach ($this->modernCommands as $name => $details) { + // A legacy command of the same name shadows this modern command at + // dispatch, so its aliases would resolve to a command `spark ` + // never reaches. Drop them entirely. + if (isset($this->commands[$name])) { + continue; + } + + foreach ($details['aliases'] as $alias) { + if (isset($this->commands[$alias]) || isset($this->modernCommands[$alias])) { + throw new LogicException(lang('Commands.aliasClashesWithCommandName', [$alias, $name])); } - unset($class); - } catch (ReflectionException $e) { - $this->logger->error($e->getMessage()); + if (isset($this->aliases[$alias])) { + throw new LogicException(lang('Commands.aliasClashesWithAlias', [$alias, $name, $this->aliases[$alias]])); + } + + $this->aliases[$alias] = $name; } } - - asort($this->commands); } /** - * Verifies if the command being sought is found - * in the commands list. + * Verifies if the command being sought is found in the commands list. * - * @param commands_list $commands + * @param legacy_commands $commands (no longer used) */ - public function verifyCommand(string $command, array $commands): bool + public function verifyCommand(string $command, array $commands = [], bool $legacy = true): bool { - if (isset($commands[$command])) { + if ($commands !== []) { + @trigger_error(sprintf('Since v4.8.0, the $commands parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED); + } + + if (isset($this->commands[$command]) && $legacy) { + return true; + } + + if (! $legacy && $this->resolveCommand($command) !== null) { return true; } $message = lang('CLI.commandNotFound', [$command]); - $alternatives = $this->getCommandAlternatives($command, $commands); + $alternatives = $this->getCommandAlternatives($command); if ($alternatives !== []) { $message = sprintf( @@ -172,20 +355,22 @@ public function verifyCommand(string $command, array $commands): bool } /** - * Finds alternative of `$name` among collection - * of commands. + * Finds alternative of `$name` across both legacy and modern commands. * - * @param commands_list $collection + * @param legacy_commands $collection (no longer used) * * @return list */ - protected function getCommandAlternatives(string $name, array $collection): array + protected function getCommandAlternatives(string $name, array $collection = []): array { + if ($collection !== []) { + @trigger_error(sprintf('Since v4.8.0, the $collection parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED); + } + /** @var array */ $alternatives = []; - /** @var string $commandName */ - foreach (array_keys($collection) as $commandName) { + foreach (array_keys($this->commands + $this->modernCommands + $this->aliases) as $commandName) { $lev = levenshtein($name, $commandName); if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) { @@ -197,4 +382,63 @@ protected function getCommandAlternatives(string $name, array $collection): arra return array_keys($alternatives); } + + /** + * @param ReflectionClass $class + */ + private function registerLegacyCommand(ReflectionClass $class, string $file): void + { + try { + /** @var BaseCommand $instance */ + $instance = $class->newInstance($this->logger, $this); + } catch (ReflectionException $e) { + $this->logger->error($e->getMessage()); + + return; + } + + if ($instance->group === null || isset($this->commands[$instance->name])) { + return; + } + + $this->commands[$instance->name] = [ + 'class' => $class->getName(), + 'file' => $file, + 'group' => $instance->group, + 'description' => $instance->description, + ]; + } + + /** + * @param ReflectionClass $class + */ + private function registerModernCommand(ReflectionClass $class, string $file): void + { + /** @var list> $attributes */ + $attributes = $class->getAttributes(Command::class); + + if ($attributes === []) { + return; + } + + try { + $attribute = $attributes[0]->newInstance(); + } catch (LogicException $e) { + $this->logger->error($e->getMessage()); + + return; + } + + if ($attribute->group === '' || isset($this->modernCommands[$attribute->name])) { + return; + } + + $this->modernCommands[$attribute->name] = [ + 'class' => $class->getName(), + 'file' => $file, + 'group' => $attribute->group, + 'description' => $attribute->description, + 'aliases' => $attribute->aliases, + ]; + } } diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 89415e265134..16feed451ebb 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -16,35 +16,83 @@ use CodeIgniter\CodeIgniter; use Config\App; use Config\Services; -use Exception; /** - * Console - * * @see \CodeIgniter\CLI\ConsoleTest */ class Console { + private const DEFAULT_COMMAND = 'list'; + + private string $command = ''; + + /** + * @var array|string|null> + */ + private array $options = []; + /** * Runs the current command discovered on the CLI. * - * @return int|void Exit code + * @param list $tokens * - * @throws Exception + * @return int Exit code */ - public function run() + public function run(array $tokens = []) + { + if ($tokens === []) { + $tokens = service('superglobals')->server('argv', []); + } + + $parser = new CommandLineParser($tokens); + + $arguments = $parser->getArguments(); + $this->options = $parser->getOptions(); + + $this->showHeader($this->hasParameterOption(['no-header'])); + + if ($this->hasParameterOption(['help', 'h'])) { + if ($arguments === []) { + $arguments = ['help', self::DEFAULT_COMMAND]; + } elseif ($arguments[0] !== 'help') { + array_unshift($arguments, 'help'); + } + + // Options supplied alongside --help were meant for the target command, + // not for `help` itself. Dropping them avoids feeding unknown options + // into the modern command pipeline's validator. + $this->options = []; + } + + /** @var Commands $commands */ + $commands = service('commands'); + + $this->command = array_shift($arguments) ?? self::DEFAULT_COMMAND; + + if ($commands->hasLegacyCommand($this->command)) { + $legacyOptions = $this->options; + unset($legacyOptions['no-header']); + + return $commands->runLegacy($this->command, array_merge($arguments, $legacyOptions)); + } + + return $commands->runCommand($this->command, $arguments, $this->options); + } + + public function initialize(): static { - // Create CLIRequest - $appConfig = config(App::class); - Services::createRequest($appConfig, true); - // Load Routes + Services::createRequest(config(App::class), true); service('routes')->loadRoutes(); - $params = array_merge(CLI::getSegments(), CLI::getOptions()); - $params = $this->parseParamsForHelpOption($params); - $command = array_shift($params) ?? 'list'; + return $this; + } - return service('commands')->run($command, $params); + /** + * Returns the command that is being executed. + */ + public function getCommand(): string + { + return $this->command; } /** @@ -67,24 +115,18 @@ public function showHeader(bool $suppress = false) } /** - * Introspects the `$params` passed for presence of the - * `--help` option. - * - * If present, it will be found as `['help' => null]`. - * We'll remove that as an option from `$params` and - * unshift it as argument instead. + * Checks whether any of the options are present in the command line. * - * @param array $params + * @param list $options */ - private function parseParamsForHelpOption(array $params): array + private function hasParameterOption(array $options): bool { - if (array_key_exists('help', $params)) { - unset($params['help']); - - $params = $params === [] ? ['list'] : $params; - array_unshift($params, 'help'); + foreach ($options as $option) { + if (array_key_exists($option, $this->options)) { + return true; + } } - return $params; + return false; } } diff --git a/system/CLI/Exceptions/ArgumentCountMismatchException.php b/system/CLI/Exceptions/ArgumentCountMismatchException.php new file mode 100644 index 000000000000..8772dbdede18 --- /dev/null +++ b/system/CLI/Exceptions/ArgumentCountMismatchException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when the number of arguments passed to a command does not match the expected count. + */ +final class ArgumentCountMismatchException extends RuntimeException +{ +} diff --git a/system/CLI/Exceptions/CommandNotAvailableException.php b/system/CLI/Exceptions/CommandNotAvailableException.php new file mode 100644 index 000000000000..0afc8899dd04 --- /dev/null +++ b/system/CLI/Exceptions/CommandNotAvailableException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when a command is found but is not currently available for execution. + */ +final class CommandNotAvailableException extends RuntimeException +{ +} diff --git a/system/CLI/Exceptions/CommandNotFoundException.php b/system/CLI/Exceptions/CommandNotFoundException.php new file mode 100644 index 000000000000..2b8d32e2835d --- /dev/null +++ b/system/CLI/Exceptions/CommandNotFoundException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when an unknown command is attempted to be executed. + */ +final class CommandNotFoundException extends RuntimeException +{ + public function __construct(string $command) + { + parent::__construct(lang('CLI.commandNotFound', [$command])); + } +} diff --git a/system/CLI/Exceptions/InvalidArgumentDefinitionException.php b/system/CLI/Exceptions/InvalidArgumentDefinitionException.php new file mode 100644 index 000000000000..8c00d1bafac1 --- /dev/null +++ b/system/CLI/Exceptions/InvalidArgumentDefinitionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Exception thrown when an invalid argument definition is provided for a spark command. + */ +final class InvalidArgumentDefinitionException extends InvalidArgumentException +{ +} diff --git a/system/CLI/Exceptions/InvalidOptionDefinitionException.php b/system/CLI/Exceptions/InvalidOptionDefinitionException.php new file mode 100644 index 000000000000..1418f21999f8 --- /dev/null +++ b/system/CLI/Exceptions/InvalidOptionDefinitionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Exception thrown when an invalid option definition is provided for a spark command. + */ +final class InvalidOptionDefinitionException extends InvalidArgumentException +{ +} diff --git a/system/CLI/Exceptions/OptionValueMismatchException.php b/system/CLI/Exceptions/OptionValueMismatchException.php new file mode 100644 index 000000000000..140eea39ff56 --- /dev/null +++ b/system/CLI/Exceptions/OptionValueMismatchException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when a provided option value does not match its definition. + */ +final class OptionValueMismatchException extends RuntimeException +{ +} diff --git a/system/CLI/Exceptions/UnknownOptionException.php b/system/CLI/Exceptions/UnknownOptionException.php new file mode 100644 index 000000000000..4d07626b258f --- /dev/null +++ b/system/CLI/Exceptions/UnknownOptionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when unknown options are provided to a CLI command. + */ +final class UnknownOptionException extends RuntimeException +{ +} diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index 7d66b8b290af..07158cd65e26 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -100,18 +100,6 @@ trait GeneratorTrait */ private $params = []; - /** - * Execute the command. - * - * @param array $params - * - * @deprecated use generateClass() instead - */ - protected function execute(array $params): void - { - $this->generateClass($params); - } - /** * Generates a class file from an existing template. * diff --git a/system/CLI/Input/Argument.php b/system/CLI/Input/Argument.php new file mode 100644 index 000000000000..7e53261fc9dd --- /dev/null +++ b/system/CLI/Input/Argument.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\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; + +/** + * Value object describing a single positional argument declared by a spark command. + */ +final readonly class Argument +{ + /** + * @var non-empty-string + */ + public string $name; + + /** + * @var list|string|null + */ + public array|string|null $default; + + /** + * @param list|string|null $default + * + * @throws InvalidArgumentDefinitionException + */ + public function __construct( + string $name, + public string $description = '', + public bool $required = false, + public bool $isArray = false, + array|string|null $default = null, + ) { + if ($name === '') { + throw new InvalidArgumentDefinitionException(lang('Commands.emptyArgumentName')); + } + + if (preg_match('/[^a-zA-Z0-9_-]/', $name) !== 0) { + throw new InvalidArgumentDefinitionException(lang('Commands.invalidArgumentName', [$name])); + } + + if ($name === 'extra_arguments') { + throw new InvalidArgumentDefinitionException(lang('Commands.reservedArgumentName')); + } + + $this->name = $name; + + if ($this->isArray && $this->required) { + throw new InvalidArgumentDefinitionException(lang('Commands.arrayArgumentCannotBeRequired', [$this->name])); + } + + if ($this->required && $default !== null) { + throw new InvalidArgumentDefinitionException(lang('Commands.requiredArgumentNoDefault', [$this->name])); + } + + if ($this->isArray) { + if ($default !== null && ! is_array($default)) { + throw new InvalidArgumentDefinitionException(lang('Commands.arrayArgumentInvalidDefault', [$this->name])); + } + + $default ??= []; + } elseif (! $this->required) { + if ($default === null) { + throw new InvalidArgumentDefinitionException(lang('Commands.optionalArgumentNoDefault', [$this->name])); + } + + if (is_array($default)) { + throw new InvalidArgumentDefinitionException(lang('Commands.nonArrayArgumentWithArrayDefault', [$this->name])); + } + } + + $this->default = $default; + } +} diff --git a/system/CLI/Input/Option.php b/system/CLI/Input/Option.php new file mode 100644 index 000000000000..d31bddb68e68 --- /dev/null +++ b/system/CLI/Input/Option.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; + +/** + * Value object describing a single option declared by a command. + */ +final readonly class Option +{ + /** + * @var non-empty-string + */ + public string $name; + + /** + * @var non-empty-string|null + */ + public ?string $shortcut; + + public bool $acceptsValue; + + /** + * @var non-empty-string|null + */ + public ?string $valueLabel; + + /** + * @var non-empty-string|null + */ + public ?string $negation; + + /** + * @var bool|list|string|null + */ + public array|bool|string|null $default; + + /** + * @param bool|list|string|null $default + * + * @throws InvalidOptionDefinitionException + */ + public function __construct( + string $name, + ?string $shortcut = null, + public string $description = '', + bool $acceptsValue = false, + public bool $requiresValue = false, + ?string $valueLabel = null, + public bool $isArray = false, + public bool $negatable = false, + array|bool|string|null $default = null, + ) { + if (str_starts_with($name, '--')) { + $name = substr($name, 2); + } + + if ($name === '') { + throw new InvalidOptionDefinitionException(lang('Commands.emptyOptionName')); + } + + if (preg_match('/^-|[^a-zA-Z0-9_-]/', $name) !== 0) { + throw new InvalidOptionDefinitionException(lang('Commands.invalidOptionName', [$name])); + } + + if ($name === 'extra_options') { + throw new InvalidOptionDefinitionException(lang('Commands.reservedOptionName')); + } + + $this->name = $name; + + if ($shortcut !== null) { + if (str_starts_with($shortcut, '-')) { + $shortcut = substr($shortcut, 1); + } + + if ($shortcut === '') { + throw new InvalidOptionDefinitionException(lang('Commands.emptyShortcutName')); + } + + if (preg_match('/[^a-zA-Z0-9]/', $shortcut) !== 0) { + throw new InvalidOptionDefinitionException(lang('Commands.invalidShortcutName', [$shortcut])); + } + + if (strlen($shortcut) > 1) { + throw new InvalidOptionDefinitionException(lang('Commands.invalidShortcutNameLength', [$shortcut])); + } + } + + $this->shortcut = $shortcut; + + // A "requires value" or "is array" option implicitly accepts a value. + $acceptsValue = $acceptsValue || $requiresValue || $isArray; + + $this->acceptsValue = $acceptsValue; + + if ($isArray && $negatable) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionCannotBeArray', [$name])); + } + + if ($acceptsValue && $negatable) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionMustNotAcceptValue', [$name])); + } + + if ($isArray && ! $requiresValue) { + throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionMustRequireValue', [$name])); + } + + if (! $acceptsValue && ! $negatable && $default !== null) { + throw new InvalidOptionDefinitionException(lang('Commands.optionNoValueAndNoDefault', [$name])); + } + + if ($requiresValue && ! $isArray && ! is_string($default)) { + throw new InvalidOptionDefinitionException(lang('Commands.optionRequiresStringDefaultValue', [$name])); + } + + if ($negatable && ! is_bool($default)) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionInvalidDefault', [$name])); + } + + if ($isArray) { + if ($default !== null && ! is_array($default)) { + throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionInvalidDefault', [$name])); + } + + if ($default === []) { + throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionEmptyArrayDefault', [$name])); + } + + $default ??= []; + } + + $this->valueLabel = $acceptsValue ? ($valueLabel ?? $name) : null; + $this->negation = $negatable ? sprintf('no-%s', $name) : null; + $this->default = $acceptsValue || $negatable ? $default : false; + } +} diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php index b69c19e2eee1..2fc8a739c32b 100644 --- a/system/CLI/InputOutput.php +++ b/system/CLI/InputOutput.php @@ -42,7 +42,7 @@ public function __construct() public function input(?string $prefix = null): string { // readline() can't be tested. - if ($this->readlineSupport && ENVIRONMENT !== 'testing') { + if ($this->readlineSupport && ! service('environment')->isTesting()) { return readline($prefix); // @codeCoverageIgnore } diff --git a/system/CLI/NullInputOutput.php b/system/CLI/NullInputOutput.php new file mode 100644 index 000000000000..1686f564adae --- /dev/null +++ b/system/CLI/NullInputOutput.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * An InputOutput sink that discards all output and never reads input. + */ +final class NullInputOutput extends InputOutput +{ + public function fwrite($handle, string $string): void + { + } + + public function input(?string $prefix = null): string + { + return ''; + } +} diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php index 4cedb67e9538..1ba25e84be20 100644 --- a/system/Cache/CacheInterface.php +++ b/system/Cache/CacheInterface.php @@ -44,11 +44,11 @@ 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 + * @param string $key Cache item name + * @param (callable(mixed): int)|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; + public function remember(string $key, callable|int $ttl, Closure $callback): mixed; /** * Deletes a specific item from the cache store. diff --git a/system/Cache/Exceptions/CacheException.php b/system/Cache/Exceptions/CacheException.php index c4871a00e8b4..80feb20eeeea 100644 --- a/system/Cache/Exceptions/CacheException.php +++ b/system/Cache/Exceptions/CacheException.php @@ -59,4 +59,14 @@ public static function forHandlerNotFound() { return new static(lang('Cache.handlerNotFound')); } + + /** + * Thrown when the handler cannot provide a lock store. + * + * @return static + */ + public static function forUnsupportedLockStore() + { + return new static(lang('Cache.unsupportedLockStore')); + } } diff --git a/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php index ef0f51c50dc7..b8ef4f284332 100644 --- a/system/Cache/Handlers/ApcuHandler.php +++ b/system/Cache/Handlers/ApcuHandler.php @@ -55,8 +55,12 @@ public function save(string $key, $value, int $ttl = 60): bool return apcu_store($key, $value, $ttl); } - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { + if (is_callable($ttl)) { + return parent::remember($key, $ttl, $callback); + } + $key = static::validateKey($key, $this->prefix); return apcu_entry($key, $callback, $ttl); diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index 7cf048c6a92b..653651b2bbc4 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -25,17 +25,6 @@ */ abstract class BaseHandler implements CacheInterface { - /** - * Reserved characters that cannot be used in a key or tag. May be overridden by the config. - * From https://github.com/symfony/cache-contracts/blob/c0446463729b89dd4fa62e9aeecc80287323615d/ItemInterface.php#L43 - * - * @deprecated in favor of the Cache config - */ - public const RESERVED_CHARACTERS = '{}()/\@:'; - - /** - * Maximum key length. - */ public const MAX_KEY_LENGTH = PHP_INT_MAX; /** @@ -75,7 +64,7 @@ public static function validateKey($key, $prefix = ''): string return strlen($prefix . $key) > static::MAX_KEY_LENGTH ? $prefix . md5($key) : $prefix . $key; } - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { $value = $this->get($key); @@ -83,7 +72,13 @@ public function remember(string $key, int $ttl, Closure $callback): mixed return $value; } - $this->save($key, $value = $callback(), $ttl); + $value = $callback(); + + if (is_callable($ttl)) { + $ttl = $ttl($value); + } + + $this->save($key, $value, $ttl); return $value; } diff --git a/system/Cache/Handlers/DummyHandler.php b/system/Cache/Handlers/DummyHandler.php index a30475965d6f..891f8ad31d3f 100644 --- a/system/Cache/Handlers/DummyHandler.php +++ b/system/Cache/Handlers/DummyHandler.php @@ -31,7 +31,7 @@ public function get(string $key): mixed return null; } - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { return null; } diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index adc31c8f447c..d53b20203c86 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -14,6 +14,9 @@ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Cache\Exceptions\CacheException; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; +use CodeIgniter\Cache\LockStores\FileLockStore; use CodeIgniter\I18n\Time; use Config\Cache; use Throwable; @@ -23,7 +26,7 @@ * * @see \CodeIgniter\Cache\Handlers\FileHandlerTest */ -class FileHandler extends BaseHandler +class FileHandler extends BaseHandler implements LockStoreProviderInterface { /** * Maximum key length. @@ -47,6 +50,8 @@ class FileHandler extends BaseHandler */ protected $mode; + private readonly LockStoreInterface $lockStore; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. * @@ -69,6 +74,8 @@ public function __construct(Cache $config) $this->mode = $options['mode']; $this->prefix = $config->prefix; + $this->lockStore = new FileLockStore($this->path, $this->mode, $this->prefix); + helper('filesystem'); } @@ -185,6 +192,11 @@ public function isSupported(): bool return is_writable($this->path); } + public function lockStore(): LockStoreInterface + { + return $this->lockStore; + } + /** * Does the heavy lifting of actually retrieving the file and * verifying its age. @@ -229,202 +241,4 @@ protected function getItem(string $filename): array|false return $data; } - - /** - * Writes a file to disk, or returns false if not successful. - * - * @deprecated 4.6.1 Use `write_file()` instead. - * - * @param string $path - * @param string $data - * @param string $mode - */ - protected function writeFile($path, $data, $mode = 'wb'): bool - { - if (($fp = @fopen($path, $mode)) === false) { - return false; - } - - flock($fp, LOCK_EX); - - $result = 0; - - for ($written = 0, $length = strlen($data); $written < $length; $written += $result) { - if (($result = fwrite($fp, substr($data, $written))) === false) { - break; - } - } - - flock($fp, LOCK_UN); - fclose($fp); - - return is_int($result); - } - - /** - * Deletes all files contained in the supplied directory path. - * Files must be writable or owned by the system in order to be deleted. - * If the second parameter is set to TRUE, any directories contained - * within the supplied base directory will be nuked as well. - * - * @deprecated 4.6.1 Use `delete_files()` instead. - * - * @param string $path File path - * @param bool $delDir Whether to delete any directories found in the path - * @param bool $htdocs Whether to skip deleting .htaccess and index page files - * @param int $_level Current directory depth level (default: 0; internal use only) - */ - protected function deleteFiles(string $path, bool $delDir = false, bool $htdocs = false, int $_level = 0): bool - { - // Trim the trailing slash - $path = rtrim($path, '/\\'); - - if (! $currentDir = @opendir($path)) { - return false; - } - - while (false !== ($filename = @readdir($currentDir))) { - if ($filename !== '.' && $filename !== '..') { - if (is_dir($path . DIRECTORY_SEPARATOR . $filename) && $filename[0] !== '.') { - $this->deleteFiles($path . DIRECTORY_SEPARATOR . $filename, $delDir, $htdocs, $_level + 1); - } elseif (! $htdocs || preg_match('/^(\.htaccess|index\.(html|htm|php)|web\.config)$/i', $filename) !== 1) { - @unlink($path . DIRECTORY_SEPARATOR . $filename); - } - } - } - - closedir($currentDir); - - return ($delDir && $_level > 0) ? @rmdir($path) : true; - } - - /** - * Reads the specified directory and builds an array containing the filenames, - * filesize, dates, and permissions - * - * Any sub-folders contained within the specified path are read as well. - * - * @deprecated 4.6.1 Use `get_dir_file_info()` instead. - * - * @param string $sourceDir Path to source - * @param bool $topLevelOnly Look only at the top level directory specified? - * @param bool $_recursion Internal variable to determine recursion status - do not use in calls - * - * @return array|false - */ - protected function getDirFileInfo(string $sourceDir, bool $topLevelOnly = true, bool $_recursion = false): array|false - { - static $filedata = []; - - $relativePath = $sourceDir; - $filePointer = @opendir($sourceDir); - - if (! is_bool($filePointer)) { - // reset the array and make sure $sourceDir has a trailing slash on the initial call - if ($_recursion === false) { - $filedata = []; - - $resolvedSrc = realpath($sourceDir); - $resolvedSrc = $resolvedSrc === false ? $sourceDir : $resolvedSrc; - - $sourceDir = rtrim($resolvedSrc, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - } - - // Used to be foreach (scandir($sourceDir, 1) as $file), but scandir() is simply not as fast - while (false !== $file = readdir($filePointer)) { - if (is_dir($sourceDir . $file) && $file[0] !== '.' && $topLevelOnly === false) { - $this->getDirFileInfo($sourceDir . $file . DIRECTORY_SEPARATOR, $topLevelOnly, true); - } elseif (! is_dir($sourceDir . $file) && $file[0] !== '.') { - $filedata[$file] = $this->getFileInfo($sourceDir . $file); - - $filedata[$file]['relative_path'] = $relativePath; - } - } - - closedir($filePointer); - - return $filedata; - } - - return false; - } - - /** - * Given a file and path, returns the name, path, size, date modified - * Second parameter allows you to explicitly declare what information you want returned - * Options are: name, server_path, size, date, readable, writable, executable, fileperms - * Returns FALSE if the file cannot be found. - * - * @deprecated 4.6.1 Use `get_file_info()` instead. - * - * @param string $file Path to file - * @param list|string $returnedValues Array or comma separated string of information returned - * - * @return array{ - * name?: string, - * server_path?: string, - * size?: int, - * date?: int, - * readable?: bool, - * writable?: bool, - * executable?: bool, - * fileperms?: int - * }|false - */ - protected function getFileInfo(string $file, $returnedValues = ['name', 'server_path', 'size', 'date']): array|false - { - if (! is_file($file)) { - return false; - } - - if (is_string($returnedValues)) { - $returnedValues = explode(',', $returnedValues); - } - - $fileInfo = []; - - foreach ($returnedValues as $key) { - switch ($key) { - case 'name': - $fileInfo['name'] = basename($file); - break; - - case 'server_path': - $fileInfo['server_path'] = $file; - break; - - case 'size': - $fileInfo['size'] = filesize($file); - break; - - case 'date': - $fileInfo['date'] = filemtime($file); - break; - - case 'readable': - $fileInfo['readable'] = is_readable($file); - break; - - case 'writable': - $fileInfo['writable'] = is_writable($file); - break; - - case 'executable': - $fileInfo['executable'] = is_executable($file); - break; - - case 'fileperms': - $fileInfo['fileperms'] = fileperms($file); - break; - } - } - - return $fileInfo; - } } diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 6d0c8aec4d97..957f49f3574f 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -13,6 +13,10 @@ namespace CodeIgniter\Cache\Handlers; +use CodeIgniter\Cache\Exceptions\CacheException; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; +use CodeIgniter\Cache\LockStores\MemcachedLockStore; use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; @@ -26,7 +30,7 @@ * * @see \CodeIgniter\Cache\Handlers\MemcachedHandlerTest */ -class MemcachedHandler extends BaseHandler +class MemcachedHandler extends BaseHandler implements LockStoreProviderInterface { /** * The memcached object @@ -35,6 +39,8 @@ class MemcachedHandler extends BaseHandler */ protected $memcached; + private ?LockStoreInterface $lockStore = null; + /** * Memcached Configuration * @@ -62,6 +68,7 @@ public function initialize(): void try { if (class_exists(Memcached::class)) { $this->memcached = new Memcached(); + $this->lockStore = null; if ($this->config['raw']) { $this->memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true); @@ -82,6 +89,7 @@ public function initialize(): void } } elseif (class_exists(Memcache::class)) { $this->memcached = new Memcache(); + $this->lockStore = null; if (! $this->memcached->connect($this->config['host'], $this->config['port'])) { throw new CriticalError('Cache: Memcache connection failed.'); @@ -219,6 +227,15 @@ public function isSupported(): bool return extension_loaded('memcached') || extension_loaded('memcache'); } + public function lockStore(): LockStoreInterface + { + if (! $this->memcached instanceof Memcached) { + throw CacheException::forUnsupportedLockStore(); + } + + return $this->lockStore ??= new MemcachedLockStore($this->memcached, $this->prefix); + } + public function ping(): bool { $version = $this->memcached->getVersion(); diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index c868f34550e9..02ea87313d60 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Cache\Handlers; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; +use CodeIgniter\Cache\LockStores\PredisLockStore; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; use Config\Cache; @@ -26,7 +29,7 @@ * * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest */ -class PredisHandler extends BaseHandler +class PredisHandler extends BaseHandler implements LockStoreProviderInterface { /** * Default config @@ -58,6 +61,8 @@ class PredisHandler extends BaseHandler */ protected $redis; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. */ @@ -71,7 +76,8 @@ public function __construct(Cache $config) public function initialize(): void { try { - $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + $this->lockStore = null; $this->redis->time(); } catch (Exception $e) { throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').', $e->getCode(), $e); @@ -202,6 +208,12 @@ public function isSupported(): bool return class_exists(Client::class); } + public function lockStore(): LockStoreInterface + { + // Predis applies the configured prefix at the client level. + return $this->lockStore ??= new PredisLockStore($this->redis); + } + public function ping(): bool { try { diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 05cae32da440..8a4549f2ee68 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Cache\Handlers; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; +use CodeIgniter\Cache\LockStores\RedisLockStore; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; use Config\Cache; @@ -24,7 +27,7 @@ * * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest */ -class RedisHandler extends BaseHandler +class RedisHandler extends BaseHandler implements LockStoreProviderInterface { /** * Default config @@ -54,6 +57,8 @@ class RedisHandler extends BaseHandler */ protected $redis; + private ?LockStoreInterface $lockStore = null; + /** * Note: Use `CacheFactory::getHandler()` to instantiate. */ @@ -68,7 +73,8 @@ public function initialize(): void { $config = $this->config; - $this->redis = new Redis(); + $this->redis = new Redis(); + $this->lockStore = null; try { $funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect'; @@ -219,6 +225,13 @@ public function isSupported(): bool return extension_loaded('redis'); } + public function lockStore(): LockStoreInterface + { + assert($this->redis instanceof Redis); + + return $this->lockStore ??= new RedisLockStore($this->redis, $this->prefix); + } + public function ping(): bool { if (! isset($this->redis)) { diff --git a/system/Cache/LockStoreInterface.php b/system/Cache/LockStoreInterface.php new file mode 100644 index 000000000000..ba900e292b24 --- /dev/null +++ b/system/Cache/LockStoreInterface.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\Cache; + +interface LockStoreInterface +{ + /** + * Attempts to acquire a lock for the given owner and TTL. + */ + public function acquireLock(string $key, string $owner, int $ttl): bool; + + /** + * Releases the lock only when it is currently held by the given owner. + */ + public function releaseLock(string $key, string $owner): bool; + + /** + * Releases the lock without checking ownership. + */ + public function forceReleaseLock(string $key): bool; + + /** + * Extends the lock TTL only when it is currently held by the given owner. + */ + public function refreshLock(string $key, string $owner, int $ttl): bool; + + /** + * Returns the current owner token, or null when the lock is not held. + */ + public function getLockOwner(string $key): ?string; +} diff --git a/system/Cache/LockStoreProviderInterface.php b/system/Cache/LockStoreProviderInterface.php new file mode 100644 index 000000000000..7984b8038d26 --- /dev/null +++ b/system/Cache/LockStoreProviderInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +interface LockStoreProviderInterface +{ + /** + * Returns the atomic lock store for this cache handler. + */ + public function lockStore(): LockStoreInterface; +} diff --git a/system/Cache/LockStores/FileLockStore.php b/system/Cache/LockStores/FileLockStore.php new file mode 100644 index 000000000000..0e732098ce70 --- /dev/null +++ b/system/Cache/LockStores/FileLockStore.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\LockStores; + +use CodeIgniter\Cache\Handlers\FileHandler; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\I18n\Time; +use Throwable; + +class FileLockStore implements LockStoreInterface +{ + public function __construct( + private readonly string $path, + private readonly int $mode, + private readonly string $prefix = '', + ) { + } + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { + $data = self::readLockData($handle); + $now = Time::now()->getTimestamp(); + + if ($data !== null && $data['expires'] > $now) { + return false; + } + + return self::writeLockData($handle, $owner, $now + $ttl); + }); + } + + public function releaseLock(string $key, string $owner): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner): bool { + $data = self::readLockData($handle); + + if ($data === null || $data['owner'] !== $owner) { + return false; + } + + return self::clearLockFile($handle); + }); + } + + public function forceReleaseLock(string $key): bool + { + return $this->withLockFile($key, static fn ($handle): bool => self::clearLockFile($handle)); + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool { + $data = self::readLockData($handle); + $now = Time::now()->getTimestamp(); + + if ($data === null || $data['owner'] !== $owner || $data['expires'] <= $now) { + return false; + } + + return self::writeLockData($handle, $owner, $now + $ttl); + }); + } + + public function getLockOwner(string $key): ?string + { + $owner = null; + + $this->withLockFile($key, static function ($handle) use (&$owner): bool { + $data = self::readLockData($handle); + + if ($data === null) { + return true; + } + + if ($data['expires'] <= Time::now()->getTimestamp()) { + self::clearLockFile($handle); + + return true; + } + + $owner = $data['owner']; + + return true; + }, false); + + return $owner; + } + + /** + * @param callable(resource): bool $callback + */ + private function withLockFile(string $key, callable $callback, bool $create = true): bool + { + $key = FileHandler::validateKey($key, $this->prefix); + $handle = @fopen($this->path . $key, $create ? 'c+b' : 'r+b'); + + if ($handle === false) { + return false; + } + + try { + if (! flock($handle, LOCK_EX)) { + return false; + } + + return $callback($handle); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + + if (is_file($this->path . $key)) { + try { + chmod($this->path . $key, $this->mode); + } catch (Throwable $e) { + log_message('debug', 'Failed to set mode on cache lock file: ' . $e); + } + } + } + } + + /** + * @param resource $handle + * + * @return array{owner: string, expires: int}|null + */ + private static function readLockData($handle): ?array + { + rewind($handle); + + $content = stream_get_contents($handle); + + if ($content === false || $content === '') { + return null; + } + + try { + $data = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return null; + } + + if (! is_array($data) || ! isset($data['owner'], $data['expires']) || ! is_string($data['owner']) || ! is_int($data['expires'])) { + return null; + } + + return $data; + } + + /** + * @param resource $handle + */ + private static function writeLockData($handle, string $owner, int $expires): bool + { + rewind($handle); + + if (! ftruncate($handle, 0)) { + return false; + } + + try { + $content = json_encode(['owner' => $owner, 'expires' => $expires], JSON_THROW_ON_ERROR); + } catch (Throwable) { + return false; + } + + if (fwrite($handle, $content) === false) { + return false; + } + + return fflush($handle); + } + + /** + * @param resource $handle + */ + private static function clearLockFile($handle): bool + { + rewind($handle); + + return ftruncate($handle, 0) && fflush($handle); + } +} diff --git a/system/Cache/LockStores/MemcachedLockStore.php b/system/Cache/LockStores/MemcachedLockStore.php new file mode 100644 index 000000000000..a35cc045a793 --- /dev/null +++ b/system/Cache/LockStores/MemcachedLockStore.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\LockStores; + +use CodeIgniter\Cache\Handlers\MemcachedHandler; +use CodeIgniter\Cache\LockStoreInterface; +use Memcached; + +class MemcachedLockStore implements LockStoreInterface +{ + private const RELEASE_TTL = 2; + + public function __construct( + private readonly Memcached $memcached, + private readonly string $prefix = '', + ) { + } + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + $key = MemcachedHandler::validateKey($key, $this->prefix); + + return $this->memcached->add($key, $owner, $ttl); + } + + public function releaseLock(string $key, string $owner): bool + { + $key = MemcachedHandler::validateKey($key, $this->prefix); + + [$value, $cas] = $this->getValueAndCas($key); + + if ($value !== $owner || $cas === null) { + return false; + } + + // Memcached has no atomic compare-and-delete command. CAS narrows the + // release race by first shortening only the current owner's value. + if (! $this->memcached->cas($cas, $key, $owner, self::RELEASE_TTL)) { + return false; + } + + return $this->memcached->delete($key); + } + + public function forceReleaseLock(string $key): bool + { + $key = MemcachedHandler::validateKey($key, $this->prefix); + + if ($this->memcached->delete($key)) { + return true; + } + + return $this->memcached->getResultCode() === Memcached::RES_NOTFOUND; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + $key = MemcachedHandler::validateKey($key, $this->prefix); + + [$value, $cas] = $this->getValueAndCas($key); + + if ($value !== $owner || $cas === null) { + return false; + } + + return $this->memcached->cas($cas, $key, $owner, $ttl); + } + + public function getLockOwner(string $key): ?string + { + $key = MemcachedHandler::validateKey($key, $this->prefix); + $owner = $this->memcached->get($key); + + if ($this->memcached->getResultCode() !== Memcached::RES_SUCCESS) { + return null; + } + + return is_string($owner) ? $owner : null; + } + + /** + * @return array{0: mixed, 1: float|int|null} + */ + private function getValueAndCas(string $key): array + { + $extended = $this->memcached->get($key, null, Memcached::GET_EXTENDED); + + if (! is_array($extended) || ! array_key_exists('value', $extended) || ! array_key_exists('cas', $extended)) { + return [null, null]; + } + + return [$extended['value'], $extended['cas']]; + } +} diff --git a/system/Cache/LockStores/PredisLockStore.php b/system/Cache/LockStores/PredisLockStore.php new file mode 100644 index 000000000000..d15d3c6394eb --- /dev/null +++ b/system/Cache/LockStores/PredisLockStore.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\LockStores; + +use CodeIgniter\Cache\Handlers\PredisHandler; +use CodeIgniter\Cache\LockStoreInterface; +use Predis\Client; +use Predis\Response\Status; + +class PredisLockStore implements LockStoreInterface +{ + public function __construct(private readonly Client $redis) + { + } + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + $key = PredisHandler::validateKey($key); + $result = $this->redis->set($key, $owner, 'EX', $ttl, 'NX'); + + return $result instanceof Status && $result->getPayload() === 'OK'; + } + + public function releaseLock(string $key, string $owner): bool + { + $key = PredisHandler::validateKey($key); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + + return 0 + LUA; + + return $this->redis->eval($script, 1, $key, $owner) === 1; + } + + public function forceReleaseLock(string $key): bool + { + $key = PredisHandler::validateKey($key); + $deleted = $this->redis->del($key); + + return is_int($deleted) && $deleted >= 0; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + $key = PredisHandler::validateKey($key); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + end + + return 0 + LUA; + + return $this->redis->eval($script, 1, $key, $owner, (string) $ttl) === 1; + } + + public function getLockOwner(string $key): ?string + { + $key = PredisHandler::validateKey($key); + $owner = $this->redis->get($key); + + return is_string($owner) ? $owner : null; + } +} diff --git a/system/Cache/LockStores/RedisLockStore.php b/system/Cache/LockStores/RedisLockStore.php new file mode 100644 index 000000000000..76623bc82c70 --- /dev/null +++ b/system/Cache/LockStores/RedisLockStore.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\LockStores; + +use CodeIgniter\Cache\Handlers\RedisHandler; +use CodeIgniter\Cache\LockStoreInterface; +use Redis; + +class RedisLockStore implements LockStoreInterface +{ + public function __construct( + private readonly Redis $redis, + private readonly string $prefix = '', + ) { + } + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + + return (bool) $this->redis->set($key, $owner, ['nx', 'ex' => $ttl]); + } + + public function releaseLock(string $key, string $owner): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + + return 0 + LUA; + + return (int) $this->redis->eval($script, [$key, $owner], 1) === 1; + } + + public function forceReleaseLock(string $key): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + $deleted = $this->redis->del($key); + + return is_int($deleted) && $deleted >= 0; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + $key = RedisHandler::validateKey($key, $this->prefix); + + $script = <<<'LUA' + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + end + + return 0 + LUA; + + return (int) $this->redis->eval($script, [$key, $owner, $ttl], 1) === 1; + } + + public function getLockOwner(string $key): ?string + { + $key = RedisHandler::validateKey($key, $this->prefix); + $owner = $this->redis->get($key); + + return is_string($owner) ? $owner : null; + } +} diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 01d585eb246e..16fa7bc1b0f5 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -19,28 +19,32 @@ use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Filters\Filters; use CodeIgniter\HTTP\CLIRequest; -use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\HTTP\Exceptions\FormRequestException; use CodeIgniter\HTTP\Exceptions\RedirectException; +use CodeIgniter\HTTP\FormRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; +use CodeIgniter\HTTP\NonBufferedResponseInterface; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponsableInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\URI; +use CodeIgniter\Router\CallableParamClassifier; +use CodeIgniter\Router\ParamKind; use CodeIgniter\Router\RouteCollectionInterface; use CodeIgniter\Router\Router; use Config\App; use Config\Cache; use Config\Feature; -use Config\Kint as KintConfig; use Config\Services; -use Exception; -use Kint; -use Kint\Renderer\CliRenderer; +use Kint\Kint; use Kint\Renderer\RichRenderer; use Locale; +use ReflectionFunction; +use ReflectionFunctionAbstract; +use ReflectionMethod; use Throwable; /** @@ -55,7 +59,7 @@ class CodeIgniter /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.7.4-dev'; + public const CI_VERSION = '4.8.0-dev'; /** * App startup time. @@ -109,7 +113,7 @@ class CodeIgniter /** * Controller to use. * - * @var (Closure(mixed...): ResponseInterface|string)|string|null + * @var Closure|string|null */ protected $controller; @@ -127,15 +131,6 @@ class CodeIgniter */ protected $output; - /** - * Cache expiration time - * - * @var int seconds - * - * @deprecated 4.4.0 Moved to ResponseCache::$ttl. No longer used. - */ - protected static $cacheTTL = 0; - /** * Context * web: Invoked by HTTP request @@ -150,13 +145,6 @@ class CodeIgniter */ protected bool $enableFilters = true; - /** - * Whether to return Response object or send response. - * - * @deprecated 4.4.0 No longer used. - */ - protected bool $returnResponse = false; - /** * Application output buffering level */ @@ -221,10 +209,17 @@ private function resetKintForWorkerMode(): void return; } - $csp = service('csp'); - if ($csp->enabled()) { - RichRenderer::$js_nonce = $csp->getScriptNonce(); - RichRenderer::$css_nonce = $csp->getStyleNonce(); + // Keep CSP lazy unless it was already initialized or explicitly enabled. + if (Services::has('csp') || config(App::class)->CSPEnabled) { + $csp = service('csp'); + + if ($csp->enabled()) { + RichRenderer::$js_nonce = $csp->getScriptNonce(); + RichRenderer::$css_nonce = $csp->getStyleNonce(); + } else { + RichRenderer::$js_nonce = null; + RichRenderer::$css_nonce = null; + } } else { RichRenderer::$js_nonce = null; RichRenderer::$css_nonce = null; @@ -233,89 +228,6 @@ private function resetKintForWorkerMode(): void RichRenderer::$needs_pre_render = true; } - /** - * Initializes Kint - * - * @return void - * - * @deprecated 4.5.0 Moved to Autoloader. - */ - protected function initializeKint() - { - if (CI_DEBUG) { - $this->autoloadKint(); - $this->configureKint(); - } elseif (class_exists(Kint::class)) { - // In case that Kint is already loaded via Composer. - Kint::$enabled_mode = false; - // @codeCoverageIgnore - } - - helper('kint'); - } - - /** - * @deprecated 4.5.0 Moved to Autoloader. - */ - private function autoloadKint(): void - { - // If we have KINT_DIR it means it's already loaded via composer - if (! defined('KINT_DIR')) { - spl_autoload_register(function ($class): void { - $class = explode('\\', $class); - - if (array_shift($class) !== 'Kint') { - return; - } - - $file = SYSTEMPATH . 'ThirdParty/Kint/' . implode('/', $class) . '.php'; - - if (is_file($file)) { - require_once $file; - } - }); - - require_once SYSTEMPATH . 'ThirdParty/Kint/init.php'; - } - } - - /** - * @deprecated 4.5.0 Moved to Autoloader. - */ - private function configureKint(): void - { - $config = new KintConfig(); - - Kint::$depth_limit = $config->maxDepth; - Kint::$display_called_from = $config->displayCalledFrom; - Kint::$expanded = $config->expanded; - - if (isset($config->plugins) && is_array($config->plugins)) { - Kint::$plugins = $config->plugins; - } - - $csp = Services::csp(); - if ($csp->enabled()) { - RichRenderer::$js_nonce = $csp->getScriptNonce(); - RichRenderer::$css_nonce = $csp->getStyleNonce(); - } - - RichRenderer::$theme = $config->richTheme; - RichRenderer::$folder = $config->richFolder; - - if (isset($config->richObjectPlugins) && is_array($config->richObjectPlugins)) { - RichRenderer::$value_plugins = $config->richObjectPlugins; - } - if (isset($config->richTabPlugins) && is_array($config->richTabPlugins)) { - RichRenderer::$tab_plugins = $config->richTabPlugins; - } - - CliRenderer::$cli_colors = $config->cliColors; - CliRenderer::$force_utf8 = $config->cliForceUTF8; - CliRenderer::$detect_width = $config->cliDetectWidth; - CliRenderer::$min_terminal_width = $config->cliMinWidth; - } - /** * Launch the application! * @@ -360,7 +272,7 @@ public function run(?RouteCollectionInterface $routes = null, bool $returnRespon $this->response = $possibleResponse; } else { try { - $this->response = $this->handleRequest($routes, config(Cache::class), $returnResponse); + $this->response = $this->handleRequest($routes); } catch (ResponsableInterface $e) { $this->outputBufferingEnd(); @@ -388,9 +300,6 @@ public function run(?RouteCollectionInterface $routes = null, bool $returnRespon return null; } - /** - * Run required before filters. - */ private function runRequiredBeforeFilters(Filters $filters): ?ResponseInterface { $possibleResponse = $filters->runRequired('before'); @@ -404,14 +313,10 @@ private function runRequiredBeforeFilters(Filters $filters): ?ResponseInterface return null; } - /** - * Run required after filters. - */ private function runRequiredAfterFilters(Filters $filters): void { $filters->setResponse($this->response); - // Run required after filters $this->benchmark->start('required_after_filters'); $response = $filters->runRequired('after'); $this->benchmark->stop('required_after_filters'); @@ -452,11 +357,14 @@ public function disableFilters(): void * * @throws PageNotFoundException * @throws RedirectException - * - * @deprecated $returnResponse is deprecated. */ - protected function handleRequest(?RouteCollectionInterface $routes, Cache $cacheConfig, bool $returnResponse = false) + protected function handleRequest(?RouteCollectionInterface $routes, ?Cache $cacheConfig = null) { + if (func_num_args() > 1) { + // @todo v4.8.0: Remove this check and the $cacheConfig parameter from the method signature. + @trigger_error(sprintf('Since v4.8.0, the $cacheConfig parameter of %s is deprecated and no longer used.', __METHOD__), E_USER_DEPRECATED); + } + if ($this->request instanceof IncomingRequest && $this->request->getMethod() === 'CLI') { return $this->response->setStatusCode(405)->setBody('Method Not Allowed'); } @@ -504,9 +412,10 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache // If startController returned a Response (from an attribute or Closure), use it if ($returned instanceof ResponseInterface) { - $this->gatherOutput($cacheConfig, $returned); + $this->gatherOutput($returned); } - // Closure controller has run in startController(). + // Closure controller has run in startController() - benchmarks were + // stopped there as well. elseif (! is_callable($this->controller)) { $controller = $this->createController(); @@ -518,15 +427,12 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache Events::trigger('post_controller_constructor'); $returned = $this->runController($controller); - } else { - $this->benchmark->stop('controller_constructor'); - $this->benchmark->stop('controller'); } // If $returned is a string, then the controller output something, // probably a view, instead of echoing it directly. Send it along // so it can be used with the output. - $this->gatherOutput($cacheConfig, $returned); + $this->gatherOutput($returned); if ($this->enableFilters) { /** @var Filters $filters */ @@ -552,7 +458,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache // Skip unnecessary processing for special Responses. if ( - ! $this->response instanceof DownloadResponse + ! $this->response instanceof NonBufferedResponseInterface && ! $this->response instanceof RedirectResponse ) { // Save our current URI as the previous URI in the session @@ -565,55 +471,6 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache return $this->response; } - /** - * You can load different configurations depending on your - * current environment. Setting the environment also influences - * things like logging and error reporting. - * - * This can be set to anything, but default usage is: - * - * development - * testing - * production - * - * @codeCoverageIgnore - * - * @return void - * - * @deprecated 4.4.0 No longer used. Moved to index.php and spark. - */ - protected function detectEnvironment() - { - // Make sure ENVIRONMENT isn't already set by other means. - if (! defined('ENVIRONMENT')) { - define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production')); - } - } - - /** - * Load any custom boot files based upon the current environment. - * - * If no boot file exists, we shouldn't continue because something - * is wrong. At the very least, they should have error reporting setup. - * - * @return void - * - * @deprecated 4.5.0 Moved to system/bootstrap.php. - */ - protected function bootstrapEnvironment() - { - if (is_file(APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php')) { - require_once APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php'; - } else { - // @codeCoverageIgnoreStart - header('HTTP/1.1 503 Service Unavailable.', true, 503); - echo 'The application environment is not set correctly.'; - - exit(EXIT_ERROR); // EXIT_ERROR - // @codeCoverageIgnoreEnd - } - } - /** * Start the Benchmark * @@ -693,86 +550,6 @@ protected function getResponseObject() $this->response->setStatusCode(200); } - /** - * Force Secure Site Access? If the config value 'forceGlobalSecureRequests' - * is true, will enforce that all requests to this site are made through - * HTTPS. Will redirect the user to the current page with HTTPS, as well - * as set the HTTP Strict Transport Security header for those browsers - * that support it. - * - * @param int $duration How long the Strict Transport Security - * should be enforced for this URL. - * - * @return void - * - * @deprecated 4.5.0 No longer used. Moved to ForceHTTPS filter. - */ - protected function forceSecureAccess($duration = 31_536_000) - { - if ($this->config->forceGlobalSecureRequests !== true) { - return; - } - - force_https($duration, $this->request, $this->response); - } - - /** - * Determines if a response has been cached for the given URI. - * - * @return false|ResponseInterface - * - * @throws Exception - * - * @deprecated 4.5.0 PageCache required filter is used. No longer used. - * @deprecated 4.4.2 The parameter $config is deprecated. No longer used. - */ - public function displayCache(Cache $config) - { - $cachedResponse = $this->pageCache->get($this->request, $this->response); - if ($cachedResponse instanceof ResponseInterface) { - $this->response = $cachedResponse; - - $this->totalTime = $this->benchmark->getElapsedTime('total_execution'); - $output = $this->displayPerformanceMetrics($cachedResponse->getBody()); - $this->response->setBody($output); - - return $this->response; - } - - return false; - } - - /** - * Tells the app that the final output should be cached. - * - * @deprecated 4.4.0 Moved to ResponseCache::setTtl(). No longer used. - * - * @return void - */ - public static function cache(int $time) - { - static::$cacheTTL = $time; - } - - /** - * Caches the full response from the current request. Used for - * full-page caching for very high performance. - * - * @return bool - * - * @deprecated 4.4.0 No longer used. - */ - public function cachePage(Cache $config) - { - $headers = []; - - foreach ($this->response->headers() as $header) { - $headers[$header->getName()] = $header->getValueLine(); - } - - return cache()->save($this->generateCacheName($config), serialize(['headers' => $headers, 'output' => $this->output]), static::$cacheTTL); - } - /** * Returns an array with our basic performance stats collected. */ @@ -787,40 +564,6 @@ public function getPerformanceStats(): array ]; } - /** - * Generates the cache name to use for our full-page caching. - * - * @deprecated 4.4.0 No longer used. - */ - protected function generateCacheName(Cache $config): string - { - if ($this->request instanceof CLIRequest) { - return md5($this->request->getPath()); - } - - $uri = clone $this->request->getUri(); - - $query = $config->cacheQueryString - ? $uri->getQuery(is_array($config->cacheQueryString) ? ['only' => $config->cacheQueryString] : []) - : ''; - - return md5((string) $uri->setFragment('')->setQuery($query)); - } - - /** - * Replaces the elapsed_time and memory_usage tag. - * - * @deprecated 4.5.0 PerformanceMetrics required filter is used. No longer used. - */ - public function displayPerformanceMetrics(string $output): string - { - return str_replace( - ['{elapsed_time}', '{memory_usage}'], - [(string) $this->totalTime, number_format(memory_get_peak_usage() / 1024 / 1024, 3)], - $output, - ); - } - /** * Try to Route It - As it sounds like, works with the router to * match a route against the current URI. If the route is a @@ -863,19 +606,6 @@ protected function tryToRouteIt(?RouteCollectionInterface $routes = null) return $this->router->getFilters(); } - /** - * Determines the path to use for us to try to route to, based - * on the CLI/IncomingRequest path. - * - * @return string - * - * @deprecated 4.5.0 No longer used. - */ - protected function determinePath() - { - return $this->request->getPath(); - } - /** * Now that everything has been setup, this method attempts to run the * controller method and make the script go. If it's not able to, will @@ -892,7 +622,14 @@ protected function startController() if (is_object($this->controller) && ($this->controller::class === 'Closure')) { $controller = $this->controller; - return $controller(...$this->router->params()); + try { + $resolved = $this->resolveCallableParams(new ReflectionFunction($controller), $this->router->params()); + + return $controller(...$resolved); + } finally { + $this->benchmark->stop('controller_constructor'); + $this->benchmark->stop('controller'); + } } // No controller specified - we don't know what to do now. @@ -969,15 +706,120 @@ protected function runController($class) // The controller method param types may not be string. // So cannot set `declare(strict_types=1)` in this file. - $output = method_exists($class, '_remap') - ? $class->_remap($this->method, ...$params) - : $class->{$this->method}(...$params); - - $this->benchmark->stop('controller'); + try { + if (method_exists($class, '_remap')) { + // FormRequest injection is not supported for _remap() because its + // signature is fixed to ($method, ...$params). Instantiate the + // FormRequest manually inside _remap() if needed. + $output = $class->_remap($this->method, ...$params); + } else { + $resolved = $this->resolveMethodParams($class, $this->method, $params); + $output = $class->{$this->method}(...$resolved); + } + } finally { + $this->benchmark->stop('controller'); + } return $output; } + /** + * Resolves the final parameter list for a controller method call. + * + * @param list $routeParams URI segments from the router. + * + * @return list + */ + private function resolveMethodParams(object $class, string $method, array $routeParams): array + { + return $this->resolveCallableParams(new ReflectionMethod($class, $method), $routeParams); + } + + /** + * Shared FormRequest resolver for both controller methods and closures. + * + * Builds a sequential positional argument list for the call site. + * The supported signature shape is: required scalar route params first, + * then the FormRequest, then optional scalar params. + * + * - FormRequest subclasses are instantiated, authorized, and validated + * before being injected. + * - Variadic non-FormRequest parameters consume all remaining URI segments. + * - Scalar non-FormRequest parameters consume one URI segment each. + * - When route segments run out, a required non-FormRequest parameter stops + * iteration so PHP throws an ArgumentCountError on the call site. + * - Optional non-FormRequest parameters with no remaining segment are omitted + * from the list; PHP then applies their declared default values. + * + * @param list $routeParams URI segments from the router. + * + * @return list + */ + private function resolveCallableParams(ReflectionFunctionAbstract $reflection, array $routeParams): array + { + $resolved = []; + $routeIndex = 0; + + foreach ($reflection->getParameters() as $param) { + [$kind, $formRequestClass] = CallableParamClassifier::classify($param); + + switch ($kind) { + case ParamKind::FormRequest: + // Inject FormRequest subclasses regardless of position. + $resolved[] = $this->resolveFormRequest($formRequestClass); + + continue 2; + + case ParamKind::Variadic: + // Consume all remaining route segments. + while (array_key_exists($routeIndex, $routeParams)) { + $resolved[] = $routeParams[$routeIndex++]; + } + break 2; + + case ParamKind::Scalar: + // Consume the next route segment if one is available. + if (array_key_exists($routeIndex, $routeParams)) { + $resolved[] = $routeParams[$routeIndex++]; + + continue 2; + } + + // No more route segments. Required params stop iteration so + // that PHP throws an ArgumentCountError on the call site. + // Optional params are omitted - PHP then applies their + // declared default value. + if (! $param->isOptional()) { + break 2; + } + } + } + + return $resolved; + } + + /** + * Instantiates, authorizes, and validates a FormRequest class. + * + * If authorization or validation fails, the FormRequest returns a + * ResponseInterface. The framework wraps it in a FormRequestException + * (which implements ResponsableInterface) so the response is sent + * without reaching the controller method. + * + * @param class-string $className + */ + private function resolveFormRequest(string $className): FormRequest + { + $formRequest = new $className($this->request); + $response = $formRequest->resolveRequest(); + + if ($response !== null) { + throw new FormRequestException($response); + } + + return $formRequest; + } + /** * Displays a 404 Page Not Found error. If set, will try to * call the 404Override controller/method that was set in routing config. @@ -1012,8 +854,7 @@ protected function display404errors(PageNotFoundException $e) unset($override); - $cacheConfig = config(Cache::class); - $this->gatherOutput($cacheConfig, $returned); + $this->gatherOutput($returned); return $this->response; } @@ -1030,18 +871,15 @@ protected function display404errors(PageNotFoundException $e) * Gathers the script output from the buffer, replaces some execution * time tag in the output and displays the debug toolbar, if required. * - * @param Cache|null $cacheConfig Deprecated. No longer used. * @param ResponseInterface|string|null $returned * - * @deprecated $cacheConfig is deprecated. - * * @return void */ - protected function gatherOutput(?Cache $cacheConfig = null, $returned = null) + protected function gatherOutput($returned = null) { $this->output = $this->outputBufferingEnd(); - if ($returned instanceof DownloadResponse) { + if ($returned instanceof NonBufferedResponseInterface) { $this->response = $returned; return; @@ -1087,7 +925,7 @@ public function storePreviousURL($uri) } // Ignore unroutable responses - if ($this->response instanceof DownloadResponse || $this->response instanceof RedirectResponse) { + if ($this->response instanceof NonBufferedResponseInterface || $this->response instanceof RedirectResponse) { return; } @@ -1148,24 +986,6 @@ protected function sendResponse() $this->response->send(); } - /** - * Exits the application, setting the exit code for CLI-based applications - * that might be watching. - * - * Made into a separate method so that it can be mocked during testing - * without actually stopping script execution. - * - * @param int $code - * - * @deprecated 4.4.0 No longer Used. Moved to index.php. - * - * @return void - */ - protected function callExit($code) - { - exit($code); // @codeCoverageIgnore - } - /** * Sets the app context. * diff --git a/system/Commands/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php index 32f9466a4939..30cda0a3a251 100644 --- a/system/Commands/Cache/ClearCache.php +++ b/system/Commands/Cache/ClearCache.php @@ -13,75 +13,47 @@ namespace CodeIgniter\Commands\Cache; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; use Config\Cache; /** - * Clears current cache. + * Clears the current system caches. */ -class ClearCache extends BaseCommand +#[Command(name: 'cache:clear', description: 'Clears the current system caches.', group: 'Cache')] +class ClearCache extends AbstractCommand { - /** - * Command grouping. - * - * @var string - */ - protected $group = 'Cache'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'cache:clear'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Clears the current system caches.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'cache:clear []'; - - /** - * the Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'driver' => 'The cache driver to use', - ]; + protected function configure(): void + { + $this->addArgument(new Argument( + name: 'driver', + description: 'The cache driver to use.', + default: config(Cache::class)->handler, + )); + } - /** - * Clears the cache - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { - $config = config(Cache::class); - $handler = $params[0] ?? $config->handler; + $driver = $arguments['driver']; + $config = config(Cache::class); - if (! array_key_exists($handler, $config->validHandlers)) { - CLI::error(lang('Cache.invalidHandler', [$handler])); + if (! array_key_exists($driver, $config->validHandlers)) { + CLI::error(lang('Cache.invalidHandler', [$driver])); return EXIT_ERROR; } - $config->handler = $handler; + $config->handler = $driver; if (! service('cache', $config)->clean()) { - CLI::error('Error while clearing the cache.'); + CLI::error(sprintf('Error occurred while clearing the cache using the "%s" driver.', $driver)); return EXIT_ERROR; } - CLI::write(CLI::color('Cache cleared.', 'green')); + CLI::write(sprintf('Cache cleared using the "%s" driver.', $driver), 'green'); return EXIT_SUCCESS; } diff --git a/system/Commands/Cache/InfoCache.php b/system/Commands/Cache/InfoCache.php index abeabd7fed24..9e49b45da4e3 100644 --- a/system/Commands/Cache/InfoCache.php +++ b/system/Commands/Cache/InfoCache.php @@ -13,64 +13,37 @@ namespace CodeIgniter\Commands\Cache; -use CodeIgniter\Cache\CacheFactory; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; /** - * Shows information on the cache. + * Shows file cache information in the current system. */ -class InfoCache extends BaseCommand +#[Command(name: 'cache:info', description: 'Shows file cache information in the current system.', group: 'Cache')] +class InfoCache extends AbstractCommand { - /** - * Command grouping. - * - * @var string - */ - protected $group = 'Cache'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'cache:info'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Shows file cache information in the current system.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'cache:info'; - - /** - * Clears the cache - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { $config = config(Cache::class); - helper('number'); if ($config->handler !== 'file') { - CLI::error('This command only supports the file cache handler.'); + CLI::error(sprintf( + 'This command only supports the file cache handler. The configured handler is "%s".', + $config->handler, + )); - return; + return EXIT_ERROR; } - $cache = CacheFactory::getHandler($config); - $caches = $cache->getCacheInfo(); - $tbody = []; + $cache = service('cache', $config); + $tbody = []; + + helper('number'); - foreach ($caches as $key => $field) { + foreach ($cache->getCacheInfo() as $key => $field) { $tbody[] = [ $key, clean_path($field['server_path']), @@ -79,13 +52,13 @@ public function run(array $params) ]; } - $thead = [ + CLI::table($tbody, [ CLI::color('Name', 'green'), CLI::color('Server Path', 'green'), CLI::color('Size', 'green'), CLI::color('Date', 'green'), - ]; + ]); - CLI::table($tbody, $thead); + return EXIT_SUCCESS; } } diff --git a/system/Commands/Database/CreateDatabase.php b/system/Commands/Database/CreateDatabase.php index facd060abf6e..ce1cf3159365 100644 --- a/system/Commands/Database/CreateDatabase.php +++ b/system/Commands/Database/CreateDatabase.php @@ -13,92 +13,79 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Config\Factories; -use CodeIgniter\Database\SQLite3\Connection; +use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection; use Config\Database; use Throwable; /** * Creates a new database. */ -class CreateDatabase extends BaseCommand +#[Command(name: 'db:create', description: 'Create a new database schema.', group: 'Database')] +class CreateDatabase extends AbstractCommand { /** - * The group the command is lumped under - * when listing commands. - * - * @var string + * @var list */ - protected $group = 'Database'; + private const VALID_EXTENSIONS = ['db', 'sqlite']; - /** - * The Command's name - * - * @var string - */ - protected $name = 'db:create'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Create a new database schema.'; + protected function configure(): void + { + $this + ->addArgument(new Argument( + name: 'db_name', + description: 'The database name to use.', + required: true, + )) + ->addOption(new Option( + name: 'ext', + description: 'File extension of the database file for SQLite3. Can be `db` or `sqlite`.', + requiresValue: true, + default: 'db', + )); + } - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'db:create [options]'; + protected function interact(array &$arguments, array &$options): void + { + if ($arguments === []) { + $arguments[] = CLI::prompt('Database name', null, 'required'); + } - /** - * The Command's arguments - * - * @var array - */ - protected $arguments = [ - 'db_name' => 'The database name to use', - ]; + $ext = $this->getUnboundOption('ext', $options); - /** - * The Command's options - * - * @var array - */ - protected $options = [ - '--ext' => 'File extension of the database file for SQLite3. Can be `db` or `sqlite`. Defaults to `db`.', - ]; + if (is_string($ext) && ! in_array($ext, self::VALID_EXTENSIONS, true)) { + $options['ext'] = CLI::prompt('Please choose a valid file extension', self::VALID_EXTENSIONS, 'required'); + } + } - /** - * Creates a new database. - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { - $name = array_shift($params); - - if (empty($name)) { - $name = CLI::prompt('Database name', null, 'required'); // @codeCoverageIgnore - } + $name = $arguments['db_name']; + assert(is_string($name)); try { $config = config(Database::class); // Set to an empty database to prevent connection errors. - $group = ENVIRONMENT === 'testing' ? 'tests' : $config->defaultGroup; + $group = service('environment')->isTesting() ? 'tests' : $config->defaultGroup; $config->{$group}['database'] = ''; $db = Database::connect(); - // Special SQLite3 handling - if ($db instanceof Connection) { - $ext = $params['ext'] ?? CLI::getOption('ext') ?? 'db'; + if ($db instanceof SQLite3Connection) { + $ext = $options['ext']; + assert(is_string($ext)); - if (! in_array($ext, ['db', 'sqlite'], true)) { - $ext = CLI::prompt('Please choose a valid file extension', ['db', 'sqlite']); // @codeCoverageIgnore + if (! in_array($ext, self::VALID_EXTENSIONS, true)) { + CLI::error(sprintf('Invalid file extension "%s". Use either `db` or `sqlite`.', $ext), 'light_gray', 'red'); + + return EXIT_ERROR; } if ($name !== ':memory:') { @@ -113,9 +100,8 @@ public function run(array $params) if (is_file($dbName)) { CLI::error("Database \"{$dbName}\" already exists.", 'light_gray', 'red'); - CLI::newLine(); - return; + return EXIT_ERROR; } unset($dbName); @@ -128,24 +114,23 @@ public function run(array $params) if (! is_file($db->getDatabase()) && $name !== ':memory:') { // @codeCoverageIgnoreStart CLI::error('Database creation failed.', 'light_gray', 'red'); - CLI::newLine(); - return; + return EXIT_ERROR; // @codeCoverageIgnoreEnd } } elseif (! Database::forge()->createDatabase($name)) { - // @codeCoverageIgnoreStart CLI::error('Database creation failed.', 'light_gray', 'red'); - CLI::newLine(); - return; - // @codeCoverageIgnoreEnd + return EXIT_ERROR; } CLI::write("Database \"{$name}\" successfully created.", 'green'); - CLI::newLine(); + + return EXIT_SUCCESS; } catch (Throwable $e) { - $this->showError($e); + $this->renderThrowable($e); + + return EXIT_ERROR; } finally { Factories::reset('config'); Database::connect(null, false); diff --git a/system/Commands/Database/Migrate.php b/system/Commands/Database/Migrate.php index 9be1a894d4e1..e91aa972ef9f 100644 --- a/system/Commands/Database/Migrate.php +++ b/system/Commands/Database/Migrate.php @@ -13,81 +13,75 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\CLI\SignalTrait; +use CodeIgniter\Database\MigrationRunner; use Throwable; /** - * Runs all new migrations. + * Locates and runs all new migrations against the database. */ -class Migrate extends BaseCommand +#[Command( + name: 'migrate', + description: 'Locates and runs all new migrations against the database.', + group: 'Database', +)] +class Migrate extends AbstractCommand { use SignalTrait; - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Database'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'migrate'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Locates and runs all new migrations against the database.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'migrate [options]'; - - /** - * the Command's Options - * - * @var array - */ - protected $options = [ - '-n' => 'Set migration namespace', - '-g' => 'Set database group', - '--all' => 'Set for all namespaces, will ignore (-n) option', - ]; - - /** - * Ensures that all migrations have been run. - */ - public function run(array $params) + protected function configure(): void { + $this + ->addOption(new Option( + name: 'namespace', + shortcut: 'n', + description: 'Set migration namespace.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'group', + shortcut: 'g', + description: 'Set database group.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'all', + description: 'Set for all namespaces. This will ignore the `--namespace` option.', + )); + } + + protected function execute(array $arguments, array $options): int + { + /** @var MigrationRunner $runner */ $runner = service('migrations'); $runner->clearCliMessages(); CLI::write(lang('Migrations.latest'), 'yellow'); - $namespace = $params['n'] ?? CLI::getOption('n'); - $group = $params['g'] ?? CLI::getOption('g'); + $namespace = $options['namespace']; + assert(is_string($namespace)); + + $group = $options['group']; + assert(is_string($group)); + + $group = $group !== '' ? $group : null; try { - if (array_key_exists('all', $params) || CLI::getOption('all')) { + if ($options['all'] === true) { $runner->setNamespace(null); - } elseif ($namespace) { + } elseif ($namespace !== '') { $runner->setNamespace($namespace); } $this->withSignalsBlocked(static function () use ($runner, $group): void { if (! $runner->latest($group)) { - CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); } }); @@ -99,10 +93,11 @@ public function run(array $params) CLI::write(lang('Migrations.migrated'), 'green'); - // @codeCoverageIgnoreStart + return EXIT_SUCCESS; } catch (Throwable $e) { - $this->showError($e); - // @codeCoverageIgnoreEnd + $this->renderThrowable($e); + + return EXIT_ERROR; } } } diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php index b7863a001438..c8d867059108 100644 --- a/system/Commands/Database/MigrateRefresh.php +++ b/system/Commands/Database/MigrateRefresh.php @@ -13,82 +13,102 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\CLI\SignalTrait; /** - * Does a rollback followed by a latest to refresh the current state - * of the database. + * Does a rollback followed by a latest to refresh the current state of the database. */ -class MigrateRefresh extends BaseCommand +#[Command( + name: 'migrate:refresh', + description: 'Does a rollback followed by a latest to refresh the current state of the database.', + group: 'Database', +)] +class MigrateRefresh extends AbstractCommand { use SignalTrait; - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Database'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'migrate:refresh'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Does a rollback followed by a latest to refresh the current state of the database.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'migrate:refresh [options]'; - - /** - * the Command's Options - * - * @var array - */ - protected $options = [ - '-n' => 'Set migration namespace', - '-g' => 'Set database group', - '--all' => 'Set latest for all namespace, will ignore (-n) option', - '-f' => 'Force command - this option allows you to bypass the confirmation question when running this command in a production environment', - ]; - - /** - * Does a rollback followed by a latest to refresh the current state - * of the database. - */ - public function run(array $params) + protected function configure(): void { - $params['b'] = 0; + $this + ->addOption(new Option( + name: 'namespace', + shortcut: 'n', + description: 'Set migration namespace.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'group', + shortcut: 'g', + description: 'Set database group.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'all', + description: 'Set latest for all namespaces. This will ignore the `--namespace` option.', + )) + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Bypass the confirmation question when running this command in a production environment.', + )); + } + + protected function interact(array &$arguments, array &$options): void + { + if (! service('environment')->isProduction()) { + return; + } + + if ($this->hasUnboundOption('force', $options)) { + return; + } - if (ENVIRONMENT === 'production') { - // @codeCoverageIgnoreStart - $force = array_key_exists('f', $params) || CLI::getOption('f'); + if (CLI::prompt(lang('Migrations.refreshConfirm'), ['y', 'n']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option + } + } - if (! $force && CLI::prompt(lang('Migrations.refreshConfirm'), ['y', 'n']) === 'n') { - return; - } + protected function execute(array $arguments, array $options): int + { + if (service('environment')->isProduction() && $options['force'] === false) { + return EXIT_ERROR; + } + + $namespace = $options['namespace']; + assert(is_string($namespace)); + + $group = $options['group']; + assert(is_string($group)); + + // A target batch of 0 rolls everything back before re-applying. + $rollbackOptions = ['batch' => '0']; + $migrateOptions = []; + + if ($options['force'] === true) { + $rollbackOptions['force'] = null; + } + + if ($namespace !== '') { + $migrateOptions['namespace'] = $namespace; + } + + if ($group !== '') { + $migrateOptions['group'] = $group; + } - $params['f'] = null; - // @codeCoverageIgnoreEnd + if ($options['all'] === true) { + $migrateOptions['all'] = null; } - $this->withSignalsBlocked(function () use ($params): void { - $this->call('migrate:rollback', $params); - $this->call('migrate', $params); - }); + return $this->withSignalsBlocked( + fn (): int => $this->call('migrate:rollback', options: $rollbackOptions) + | $this->call('migrate', options: $migrateOptions), + ); } } diff --git a/system/Commands/Database/MigrateRollback.php b/system/Commands/Database/MigrateRollback.php index 5fa6ac171bfb..f11e3a30e6a4 100644 --- a/system/Commands/Database/MigrateRollback.php +++ b/system/Commands/Database/MigrateRollback.php @@ -13,100 +13,97 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\CLI\SignalTrait; use CodeIgniter\Database\MigrationRunner; use Throwable; /** - * Runs all of the migrations in reverse order, until they have - * all been unapplied. + * Runs the "down" method for all migrations in the last batch. */ -class MigrateRollback extends BaseCommand +#[Command( + name: 'migrate:rollback', + description: 'Runs the "down" method for all migrations in the last batch.', + group: 'Database', +)] +class MigrateRollback extends AbstractCommand { use SignalTrait; - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Database'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'migrate:rollback'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Runs the "down" method for all migrations in the last batch.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'migrate:rollback [options]'; - - /** - * the Command's Options - * - * @var array - */ - protected $options = [ - '-b' => 'Specify a batch to roll back to; e.g. "3" to return to batch #3', - '-f' => 'Force command - this option allows you to bypass the confirmation question when running this command in a production environment', - ]; - - /** - * Runs all of the migrations in reverse order, until they have - * all been unapplied. - */ - public function run(array $params) + protected function configure(): void { - if (ENVIRONMENT === 'production') { - // @codeCoverageIgnoreStart - $force = array_key_exists('f', $params) || CLI::getOption('f'); + $this + ->addOption(new Option( + name: 'batch', + shortcut: 'b', + description: 'Specify a batch to roll back to.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Bypass the confirmation question when running this command in a production environment.', + )); + } - if (! $force && CLI::prompt(lang('Migrations.rollBackConfirm'), ['y', 'n']) === 'n') { - return null; - } - // @codeCoverageIgnoreEnd + protected function interact(array &$arguments, array &$options): void + { + if (! service('environment')->isProduction()) { + return; + } + + if ($this->hasUnboundOption('force', $options)) { + return; + } + + if (CLI::prompt(lang('Migrations.rollBackConfirm'), ['y', 'n']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option + } + } + + protected function execute(array $arguments, array $options): int + { + if (service('environment')->isProduction() && $options['force'] === false) { + return EXIT_ERROR; } /** @var MigrationRunner $runner */ $runner = service('migrations'); try { - $batch = $params['b'] ?? CLI::getOption('b') ?? $runner->getLastBatch() - 1; + $batch = $options['batch']; + assert(is_string($batch)); - if (is_string($batch)) { - if (! ctype_digit($batch)) { - CLI::error('Invalid batch number: ' . $batch, 'light_gray', 'red'); - CLI::newLine(); - - return EXIT_ERROR; - } + if ($batch === '') { + $batch = $runner->getLastBatch() - 1; + } elseif (! ctype_digit($batch)) { + CLI::error('Invalid batch number: ' . $batch, 'light_gray', 'red'); + return EXIT_ERROR; + } else { $batch = (int) $batch; } CLI::write(lang('Migrations.rollingBack') . ' ' . $batch, 'yellow'); - $this->withSignalsBlocked(static function () use ($runner, $batch): void { + $exit = $this->withSignalsBlocked(static function () use ($runner, $batch): int { if (! $runner->regress($batch)) { - CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore + CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); + + return EXIT_ERROR; } + + return EXIT_SUCCESS; }); + if ($exit !== EXIT_SUCCESS) { + return $exit; + } + $messages = $runner->getCliMessages(); foreach ($messages as $message) { @@ -115,12 +112,11 @@ public function run(array $params) CLI::write('Done rolling back migrations.', 'green'); - // @codeCoverageIgnoreStart + return EXIT_SUCCESS; } catch (Throwable $e) { - $this->showError($e); - // @codeCoverageIgnoreEnd - } + $this->renderThrowable($e); - return null; + return EXIT_ERROR; + } } } diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php index 9506c2eae082..d1f6054d6bd8 100644 --- a/system/Commands/Database/MigrateStatus.php +++ b/system/Commands/Database/MigrateStatus.php @@ -13,60 +13,28 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\Database\MigrationRunner; /** * Displays a list of all migrations and whether they've been run or not. - * - * @see \CodeIgniter\Commands\Database\MigrateStatusTest */ -class MigrateStatus extends BaseCommand +#[Command( + name: 'migrate:status', + description: 'Displays a list of all migrations and whether they\'ve been run or not.', + group: 'Database', +)] +class MigrateStatus extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Database'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'migrate:status'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Displays a list of all migrations and whether they\'ve been run or not.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'migrate:status [options]'; - - /** - * the Command's Options - * - * @var array - */ - protected $options = [ - '-g' => 'Set database group', - ]; - /** * Namespaces to ignore when looking for migrations. * * @var list */ - protected $ignoredNamespaces = [ + protected array $ignoredNamespaces = [ 'CodeIgniter', 'Config', 'Kint', @@ -75,26 +43,33 @@ class MigrateStatus extends BaseCommand 'Psr\Log', ]; - /** - * Displays a list of all migrations and whether they've been run or not. - * - * @param array $params - */ - public function run(array $params) + protected function configure(): void { - $runner = service('migrations'); - $paramGroup = $params['g'] ?? CLI::getOption('g'); + $this->addOption(new Option( + name: 'group', + shortcut: 'g', + description: 'Set database group.', + requiresValue: true, + default: '', + )); + } + + protected function execute(array $arguments, array $options): int + { + /** @var MigrationRunner $runner */ + $runner = service('migrations'); + + $groupOption = $options['group']; + assert(is_string($groupOption)); - // Get all namespaces $namespaces = service('autoloader')->getNamespace(); - // Collection of migration status $status = []; foreach (array_keys($namespaces) as $namespace) { - if (ENVIRONMENT !== 'testing') { + if (! service('environment')->isTesting()) { // Make Tests\\Support discoverable for testing - $this->ignoredNamespaces[] = 'Tests\Support'; // @codeCoverageIgnore + $this->ignoredNamespaces[] = 'Tests\Support'; } if (in_array($namespace, $this->ignoredNamespaces, true)) { @@ -107,12 +82,12 @@ public function run(array $params) $migrations = $runner->findNamespaceMigrations($namespace); - if (empty($migrations)) { + if ($migrations === []) { continue; } $runner->setNamespace($namespace); - $history = $runner->getHistory((string) $paramGroup); + $history = $runner->getHistory($groupOption); ksort($migrations); foreach ($migrations as $uid => $migration) { @@ -123,7 +98,6 @@ public function run(array $params) $batch = '---'; foreach ($history as $row) { - // @codeCoverageIgnoreStart if ($runner->getObjectUid($row) !== $migration->uid) { continue; } @@ -131,7 +105,6 @@ public function run(array $params) $date = date('Y-m-d H:i:s', (int) $row->time); $group = $row->group; $batch = $row->batch; - // @codeCoverageIgnoreEnd } $status[] = [ @@ -146,12 +119,9 @@ public function run(array $params) } if ($status === []) { - // @codeCoverageIgnoreStart CLI::error(lang('Migrations.noneFound'), 'light_gray', 'red'); - CLI::newLine(); - return; - // @codeCoverageIgnoreEnd + return EXIT_ERROR; } $headers = [ @@ -164,5 +134,7 @@ public function run(array $params) ]; CLI::table($status, $headers); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Database/Seed.php b/system/Commands/Database/Seed.php index fc1c0f1ae62e..5b102dfb284e 100644 --- a/system/Commands/Database/Seed.php +++ b/system/Commands/Database/Seed.php @@ -13,72 +13,53 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; use CodeIgniter\Database\Seeder; use Config\Database; use Throwable; /** - * Runs the specified Seeder file to populate the database - * with some data. + * Runs the specified seeder to populate known data into the database. */ -class Seed extends BaseCommand +#[Command( + name: 'db:seed', + description: 'Runs the specified seeder to populate known data into the database.', + group: 'Database', +)] +class Seed extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Database'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'db:seed'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Runs the specified seeder to populate known data into the database.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'db:seed '; - - /** - * the Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'seeder_name' => 'The seeder name to run', - ]; - - /** - * Passes to Seeder to populate the database. - */ - public function run(array $params) + protected function configure(): void { - $seeder = new Seeder(new Database()); - $seedName = array_shift($params); + $this->addArgument(new Argument( + name: 'seeder_name', + description: 'The seeder name to run.', + required: true, + )); + } - if (empty($seedName)) { - $seedName = CLI::prompt(lang('Migrations.migSeeder'), null, 'required'); // @codeCoverageIgnore + protected function interact(array &$arguments, array &$options): void + { + if ($arguments === []) { + $arguments[] = CLI::prompt(lang('Migrations.migSeeder'), null, 'required'); } + } + + protected function execute(array $arguments, array $options): int + { + $seedName = $arguments['seeder_name']; + assert(is_string($seedName)); try { - $seeder->call($seedName); + (new Seeder(new Database()))->call($seedName); + + return EXIT_SUCCESS; } catch (Throwable $e) { - $this->showError($e); + $this->renderThrowable($e); + + return EXIT_ERROR; } } } diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php index d05159b1b10a..3a4fa60f401b 100644 --- a/system/Commands/Database/ShowTableInfo.php +++ b/system/Commands/Database/ShowTableInfo.php @@ -13,311 +13,286 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database; /** - * Get table data if it exists in the database. - * - * @see \CodeIgniter\Commands\Database\ShowTableInfoTest + * Retrieves information on the selected table. */ -class ShowTableInfo extends BaseCommand +#[Command( + name: 'db:table', + description: 'Retrieves information on the selected table.', + group: 'Database', +)] +class ShowTableInfo extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Database'; + private ?BaseConnection $db = null; /** - * The Command's name - * - * @var string + * @var string The sort order for table rows. */ - protected $name = 'db:table'; + private string $sortOrder = 'ASC'; - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Retrieves information on the selected table.'; + private string $dbPrefix; - /** - * the Command's usage - * - * @var string - */ - protected $usage = <<<'EOL' - db:table [] [options] - - Examples: - db:table --show - db:table --metadata - db:table my_table --metadata - db:table my_table - db:table my_table --limit-rows 5 --limit-field-value 10 --desc - EOL; + protected function configure(): void + { + $this + ->addArgument(new Argument( + name: 'table_name', + description: 'The table name to show info.', + default: '', + )) + ->addOption(new Option( + name: 'show', + description: 'Lists the names of all database tables.', + )) + ->addOption(new Option( + name: 'metadata', + description: 'Retrieves list containing field information.', + )) + ->addOption(new Option( + name: 'desc', + description: 'Sorts the table rows in DESC order.', + )) + ->addOption(new Option( + name: 'limit-rows', + description: 'Limits the number of rows.', + requiresValue: true, + default: '10', + valueLabel: 'rows', + )) + ->addOption(new Option( + name: 'limit-field-value', + description: 'Limits the length of field values.', + requiresValue: true, + default: '15', + valueLabel: 'value', + )) + ->addOption(new Option( + name: 'dbgroup', + description: 'Database group to show.', + requiresValue: true, + default: '', + valueLabel: 'group', + )) + ->addUsage('db:table --show') + ->addUsage('db:table --metadata') + ->addUsage('db:table my_table --metadata') + ->addUsage('db:table my_table') + ->addUsage('db:table my_table --limit-rows 5 --limit-field-value 10 --desc'); + } - /** - * The Command's arguments - * - * @var array - */ - protected $arguments = [ - 'table_name' => 'The table name to show info', - ]; + protected function interact(array &$arguments, array &$options): void + { + if ($this->hasUnboundOption('show', $options)) { + return; + } - /** - * The Command's options - * - * @var array - */ - protected $options = [ - '--show' => 'Lists the names of all database tables.', - '--metadata' => 'Retrieves list containing field information.', - '--desc' => 'Sorts the table rows in DESC order.', - '--limit-rows' => 'Limits the number of rows. Default: 10.', - '--limit-field-value' => 'Limits the length of field values. Default: 15.', - '--dbgroup' => 'Database group to show.', - ]; + try { + $db = Database::connect($this->resolveDbGroup($this->getUnboundOption('dbgroup', $options))); + } catch (InvalidArgumentException) { + return; + } - /** - * @var list> Table Data. - */ - private array $tbody; + $tables = $db->listTables(); - private ?BaseConnection $db = null; + if ($tables === false || $tables === []) { + return; + } - /** - * @var bool Sort the table rows in DESC order or not. - */ - private bool $sortDesc = false; + while (! in_array($arguments[0] ?? '', $tables, true)) { + $tableKey = CLI::promptByKey( + ['Here is the list of your database tables:', 'Which table do you want to see?'], + $tables, + 'required', + ); + CLI::newLine(); - private string $DBPrefix; + $arguments[0] = $tables[$tableKey] ?? ''; + } + } - public function run(array $params) + protected function execute(array $arguments, array $options): int { - $dbGroup = $params['dbgroup'] ?? CLI::getOption('dbgroup'); - try { - $this->db = Database::connect($dbGroup); + $this->db = Database::connect($this->resolveDbGroup($options['dbgroup'])); } catch (InvalidArgumentException $e) { CLI::error($e->getMessage()); return EXIT_ERROR; } - $this->DBPrefix = $this->db->getPrefix(); + $this->dbPrefix = $this->db->getPrefix(); - $this->showDBConfig(); + $this->showDbConfig(); $tables = $this->db->listTables(); - if (array_key_exists('desc', $params)) { - $this->sortDesc = true; - } + $this->sortOrder = $options['desc'] === true ? 'DESC' : 'ASC'; - if ($tables === []) { + if ($tables === false || $tables === []) { CLI::error('Database has no tables!', 'light_gray', 'red'); - CLI::newLine(); return EXIT_ERROR; } - if (array_key_exists('show', $params)) { + if ($options['show'] === true) { $this->showAllTables($tables); - return EXIT_ERROR; + return EXIT_SUCCESS; } - $tableName = $params[0] ?? null; - $limitRows = (int) ($params['limit-rows'] ?? 10); - $limitFieldValue = (int) ($params['limit-field-value'] ?? 15); + $tableName = $arguments['table_name']; + assert(is_string($tableName)); - while (! in_array($tableName, $tables, true)) { - $tableNameNo = CLI::promptByKey( - ['Here is the list of your database tables:', 'Which table do you want to see?'], - $tables, - 'required', + if (! in_array($tableName, $tables, true)) { + CLI::error( + $tableName === '' + ? 'No table name was specified.' + : sprintf('Table "%s" was not found in the database.', $tableName), + 'light_gray', + 'red', ); - CLI::newLine(); - $tableName = $tables[$tableNameNo] ?? null; + return EXIT_ERROR; } - if (array_key_exists('metadata', $params)) { + if ($options['metadata'] === true) { $this->showFieldMetaData($tableName); return EXIT_SUCCESS; } - $this->showDataOfTable($tableName, $limitRows, $limitFieldValue); + $limitRows = $options['limit-rows']; + $limitFieldValue = $options['limit-field-value']; + assert(is_string($limitRows) && is_string($limitFieldValue)); + + $this->showDataOfTable($tableName, (int) $limitRows, (int) $limitFieldValue); return EXIT_SUCCESS; } - private function showDBConfig(): void + private function resolveDbGroup(mixed $group): ?string + { + return is_string($group) && $group !== '' ? $group : null; + } + + private function showDbConfig(): void { - $data = [[ - 'hostname' => $this->db->hostname, - 'database' => $this->db->getDatabase(), - 'username' => $this->db->username, - 'DBDriver' => $this->db->getPlatform(), - 'DBPrefix' => $this->DBPrefix, - 'port' => $this->db->port, - ]]; - CLI::table( - $data, - ['hostname', 'database', 'username', 'DBDriver', 'DBPrefix', 'port'], - ); + CLI::table([[ + $this->db->hostname, + $this->db->getDatabase(), + $this->db->username, + $this->db->getPlatform(), + $this->dbPrefix, + $this->db->port, + ]], ['Hostname', 'Database', 'Username', 'DB Driver', 'DB Prefix', 'Port']); } - private function removeDBPrefix(): void + private function removeDbPrefix(): void { $this->db->setPrefix(''); } - private function restoreDBPrefix(): void + private function restoreDbPrefix(): void { - $this->db->setPrefix($this->DBPrefix); + $this->db->setPrefix($this->dbPrefix); } - /** - * Show Data of Table - * - * @return void - */ - private function showDataOfTable(string $tableName, int $limitRows, int $limitFieldValue) + private function showDataOfTable(string $tableName, int $limitRows, int $limitFieldValue): void { - CLI::write("Data of Table \"{$tableName}\":", 'black', 'yellow'); + CLI::write(sprintf('Data of "%s" table:', $tableName), 'black', 'yellow'); CLI::newLine(); - $this->removeDBPrefix(); - $thead = $this->db->getFieldNames(TableName::fromActualName($this->db->DBPrefix, $tableName)); - $this->restoreDBPrefix(); + $this->removeDbPrefix(); + + $table = TableName::fromActualName($this->db->getPrefix(), $tableName); + $fieldNames = $this->db->getFieldNames($table); // If there is a field named `id`, sort by it. - $sortField = null; - if (in_array('id', $thead, true)) { - $sortField = 'id'; + $sortField = in_array('id', $fieldNames, true) ? 'id' : ''; + + $builder = $this->db->table($table)->limit($limitRows); + + if ($sortField !== '') { + $builder->orderBy($sortField, $this->sortOrder); } - $this->tbody = $this->makeTableRows($tableName, $limitRows, $limitFieldValue, $sortField); - CLI::table($this->tbody, $thead); - } + $rows = $builder->get()->getResultArray(); - /** - * Show All Tables - * - * @param list $tables - * - * @return void - */ - private function showAllTables(array $tables) - { - CLI::write('The following is a list of the names of all database tables:', 'black', 'yellow'); - CLI::newLine(); + $this->restoreDbPrefix(); - $thead = ['ID', 'Table Name', 'Num of Rows', 'Num of Fields']; - $this->tbody = $this->makeTbodyForShowAllTables($tables); + $thead = array_map(ucfirst(...), $fieldNames); - CLI::table($this->tbody, $thead); - CLI::newLine(); + $tbody = []; + + foreach ($rows as $row) { + $tbody[] = array_map( + static fn ($item): string => mb_strlen((string) $item) > $limitFieldValue + ? mb_substr((string) $item, 0, $limitFieldValue) . '...' + : (string) $item, + $row, + ); + } + + if ($sortField === '' && $this->sortOrder === 'DESC') { + $tbody = array_reverse($tbody); + } + + CLI::table($tbody, $thead); } /** - * Make body for table - * * @param list $tables - * - * @return list> */ - private function makeTbodyForShowAllTables(array $tables): array + private function showAllTables(array $tables): void { - $this->tbody = []; + CLI::write('The following is a list of the names of all database tables:', 'black', 'yellow'); + CLI::newLine(); - $this->removeDBPrefix(); + $this->removeDbPrefix(); - foreach ($tables as $id => $tableName) { - $table = $this->db->protectIdentifiers($tableName); - $db = $this->db->query("SELECT * FROM {$table}"); + $tbody = []; - $this->tbody[] = [ + foreach ($tables as $id => $tableName) { + $tbody[] = [ $id + 1, $tableName, - $db->getNumRows(), - $db->getFieldCount(), + $this->db->table($tableName)->countAllResults(), + count($this->db->getFieldData($tableName)), ]; } - $this->restoreDBPrefix(); + $this->restoreDbPrefix(); - if ($this->sortDesc) { - krsort($this->tbody); - } + $thead = ['Id', 'Table Name', 'Num of Rows', 'Num of Fields']; - return $this->tbody; - } - - /** - * Make table rows - * - * @return list> - */ - private function makeTableRows( - string $tableName, - int $limitRows, - int $limitFieldValue, - ?string $sortField = null, - ): array { - $this->tbody = []; - - $this->removeDBPrefix(); - $builder = $this->db->table(TableName::fromActualName($this->db->DBPrefix, $tableName)); - $builder->limit($limitRows); - if ($sortField !== null) { - $builder->orderBy($sortField, $this->sortDesc ? 'DESC' : 'ASC'); - } - $rows = $builder->get()->getResultArray(); - $this->restoreDBPrefix(); - - foreach ($rows as $row) { - $row = array_map( - static fn ($item): string => mb_strlen((string) $item) > $limitFieldValue - ? mb_substr((string) $item, 0, $limitFieldValue) . '...' - : (string) $item, - $row, - ); - $this->tbody[] = $row; - } - - if ($sortField === null && $this->sortDesc) { - krsort($this->tbody); - } - - return $this->tbody; + CLI::table($this->sortOrder === 'DESC' ? array_reverse($tbody) : $tbody, $thead); } private function showFieldMetaData(string $tableName): void { - CLI::write("List of Metadata Information in Table \"{$tableName}\":", 'black', 'yellow'); + CLI::write(sprintf('List of metadata information in "%s" table:', $tableName), 'black', 'yellow'); CLI::newLine(); - $thead = ['Field Name', 'Type', 'Max Length', 'Nullable', 'Default', 'Primary Key']; + $thead = ['Field Name', 'Type', 'Max Length', 'Nullable?', 'Default', 'Primary Key?']; - $this->removeDBPrefix(); + $this->removeDbPrefix(); $fields = $this->db->getFieldData($tableName); - $this->restoreDBPrefix(); + $this->restoreDbPrefix(); + + $tbody = []; foreach ($fields as $row) { - $this->tbody[] = [ + $tbody[] = [ $row->name, $row->type, $row->max_length, @@ -327,22 +302,13 @@ private function showFieldMetaData(string $tableName): void ]; } - if ($this->sortDesc) { - krsort($this->tbody); - } - - CLI::table($this->tbody, $thead); + CLI::table($this->sortOrder === 'DESC' ? array_reverse($tbody) : $tbody, $thead); } - /** - * @param bool|int|string|null $fieldValue - */ - private function setYesOrNo($fieldValue): string + private function setYesOrNo(mixed $fieldValue): string { - if ((bool) $fieldValue) { - return CLI::color('Yes', 'green'); - } - - return CLI::color('No', 'red'); + return filter_var($fieldValue, FILTER_VALIDATE_BOOL) + ? CLI::color('Yes', 'green') + : CLI::color('No', 'red'); } } diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index 148fd264dc33..aafcead42742 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -13,90 +13,122 @@ namespace CodeIgniter\Commands\Encryption; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Config\DotEnv; use CodeIgniter\Encryption\Encryption; use Config\Paths; /** - * Generates a new encryption key. + * Generates a new encryption key and writes it in an `.env` file. */ -class GenerateKey extends BaseCommand +#[Command(name: 'key:generate', description: 'Generates a new encryption key and writes it in an `.env` file.', group: 'Encryption')] +class GenerateKey extends AbstractCommand { /** - * The Command's group. - * - * @var string + * @var list */ - protected $group = 'Encryption'; + private const VALID_PREFIXES = ['hex2bin', 'base64']; - /** - * The Command's name. - * - * @var string - */ - protected $name = 'key:generate'; + protected function configure(): void + { + $this + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Force overwrite existing key in `.env` file.', + )) + ->addOption(new Option( + name: 'length', + description: 'The length of the random string that should be returned in bytes.', + requiresValue: true, + default: '32', + )) + ->addOption(new Option( + name: 'prefix', + description: 'Prefix to prepend to encoded key (either hex2bin or base64).', + requiresValue: true, + default: 'hex2bin', + )) + ->addOption(new Option( + name: 'show', + description: 'Shows the generated key in the terminal instead of storing in the `.env` file.', + )); + } - /** - * The Command's usage. - * - * @var string - */ - protected $usage = 'key:generate [options]'; + protected function interact(array &$arguments, array &$options): void + { + $prefix = $this->getUnboundOption('prefix', $options); - /** - * The Command's short description. - * - * @var string - */ - protected $description = 'Generates a new encryption key and writes it in an `.env` file.'; + if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) { + $options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required'); + } - /** - * The command's options - * - * @var array - */ - protected $options = [ - '--force' => 'Force overwrite existing key in `.env` file.', - '--length' => 'The length of the random string that should be returned in bytes. Defaults to 32.', - '--prefix' => 'Prefix to prepend to encoded key (either hex2bin or base64). Defaults to hex2bin.', - '--show' => 'Shows the generated key in the terminal instead of storing in the `.env` file.', - ]; + if ($this->hasUnboundOption('show', $options)) { + return; + } - /** - * Actually execute the command. - */ - public function run(array $params) - { - $prefix = $params['prefix'] ?? CLI::getOption('prefix'); + if ($this->hasUnboundOption('force', $options)) { + return; + } - if (in_array($prefix, [null, true], true)) { - $prefix = 'hex2bin'; - } elseif (! in_array($prefix, ['hex2bin', 'base64'], true)) { - $prefix = CLI::prompt('Please provide a valid prefix to use.', ['hex2bin', 'base64'], 'required'); // @codeCoverageIgnore + if (env('encryption.key', '') === '') { + return; } - $length = $params['length'] ?? CLI::getOption('length'); + if (CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option + } + } - if (in_array($length, [null, true], true)) { - $length = 32; + protected function execute(array $arguments, array $options): int + { + $prefix = $options['prefix']; + + if (! in_array($prefix, self::VALID_PREFIXES, true)) { + CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix)); + + return EXIT_ERROR; } - $encodedKey = $this->generateRandomKey($prefix, $length); + $encodedKey = $this->generateRandomKey($prefix, (int) $options['length']); - if (array_key_exists('show', $params) || (bool) CLI::getOption('show')) { + if ($options['show'] === true) { CLI::write($encodedKey, 'yellow'); - CLI::newLine(); - return; + return EXIT_SUCCESS; } - if (! $this->setNewEncryptionKey($encodedKey, $params)) { - CLI::write('Error in setting new encryption key to .env file.', 'light_gray', 'red'); - CLI::newLine(); + $currentKey = env('encryption.key', ''); - return; + if ($currentKey !== '' && $options['force'] === false) { + if ($this->isInteractive()) { + CLI::write('Setting new encryption key cancelled.', 'yellow'); + + return EXIT_SUCCESS; + } + + CLI::error('Setting new encryption key aborted: pass --force to overwrite the existing key in non-interactive mode.'); + + return EXIT_ERROR; + } + + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property + $baseEnv = ROOTPATH . 'env'; + + if (! is_file($envFile) && ! is_file($baseEnv)) { + CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow'); + CLI::write(sprintf('Here\'s your new key instead: %s', CLI::color($encodedKey, 'yellow'))); + + return EXIT_ERROR; + } + + if (! $this->writeNewEncryptionKeyToFile($encodedKey, $envFile, $baseEnv)) { + CLI::error(sprintf('Failed to write new encryption key to %s.', clean_path($envFile))); + + return EXIT_ERROR; } // force DotEnv to reload the new env vars @@ -106,14 +138,16 @@ public function run(array $params) $dotenv = new DotEnv((new Paths())->envDirectory ?? ROOTPATH); // @phpstan-ignore nullCoalesce.property $dotenv->load(); - CLI::write('Application\'s new encryption key was successfully set.', 'green'); + CLI::write(sprintf('New encryption key written to %s.', clean_path($envFile)), 'green'); CLI::newLine(); + + return EXIT_SUCCESS; } /** * Generates a key and encodes it. */ - protected function generateRandomKey(string $prefix, int $length): string + private function generateRandomKey(string $prefix, int $length): string { $key = Encryption::createKey($length); @@ -125,56 +159,23 @@ protected function generateRandomKey(string $prefix, int $length): string } /** - * Sets the new encryption key in your .env file. - * - * @param array $params - */ - protected function setNewEncryptionKey(string $key, array $params): bool - { - $currentKey = env('encryption.key', ''); - - if ($currentKey !== '' && ! $this->confirmOverwrite($params)) { - // Not yet testable since it requires keyboard input - return false; // @codeCoverageIgnore - } - - return $this->writeNewEncryptionKeyToFile($currentKey, $key); - } - - /** - * Checks whether to overwrite existing encryption key. - * - * @param array $params - */ - protected function confirmOverwrite(array $params): bool - { - return (array_key_exists('force', $params) || CLI::getOption('force')) || CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y'; - } - - /** - * Writes the new encryption key to .env file. + * Writes the new encryption key to .env file. The caller is responsible + * for ensuring at least one of `$envFile` or `$baseEnv` exists. */ - protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool + private function writeNewEncryptionKeyToFile(string $newKey, string $envFile, string $baseEnv): bool { - $baseEnv = ROOTPATH . 'env'; - $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property - if (! is_file($envFile)) { - if (! is_file($baseEnv)) { - CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow'); - CLI::write('Here\'s your new key instead: ' . CLI::color($newKey, 'yellow')); - CLI::newLine(); - - return false; - } - copy($baseEnv, $envFile); } + if (! is_writable($envFile)) { + return false; + } + $oldFileContents = (string) file_get_contents($envFile); // Match an active setting line, preserving any leading whitespace and `export` prefix. - $activePattern = $this->keyPattern($oldKey); + $activePattern = '/^(\h*(?:export\h+)?encryption\.key\h*=\h*)[^\r\n]*$/m'; if (preg_match($activePattern, $oldFileContents) === 1) { $newFileContents = (string) preg_replace($activePattern, '$1' . $newKey, $oldFileContents, 1); @@ -195,18 +196,4 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): // No setting present (active or commented); append. return file_put_contents($envFile, "\nencryption.key = {$newKey}", FILE_APPEND) !== false; } - - /** - * Returns the regex used to locate an active `encryption.key = ...` setting in the `.env` - * contents. The single capture group spans everything up to (and including) the `=` and any - * separating whitespace, so a `preg_replace` substitution preserves an optional `export` - * prefix while rewriting only the value. - * - * The `$oldKey` parameter is retained for backward compatibility with subclasses that - * override this method; it is no longer consulted because the pattern matches any value. - */ - protected function keyPattern(string $oldKey): string - { - return '/^(\h*(?:export\h+)?encryption\.key\h*=\h*)[^\r\n]*$/m'; - } } diff --git a/system/Commands/Encryption/RotateKey.php b/system/Commands/Encryption/RotateKey.php new file mode 100644 index 000000000000..ade51d6f12e0 --- /dev/null +++ b/system/Commands/Encryption/RotateKey.php @@ -0,0 +1,277 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Encryption; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; +use Config\Paths; + +/** + * Rotates the encryption key, demoting the current key to `previousKeys`. + */ +#[Command( + name: 'key:rotate', + description: 'Rotates the encryption key, demoting the current key to `encryption.previousKeys` in the `.env` file.', + group: 'Encryption', +)] +class RotateKey extends AbstractCommand +{ + /** + * @var list + */ + private const VALID_PREFIXES = ['hex2bin', 'base64']; + + protected function configure(): void + { + $this + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Skip the key rotation confirmation.', + )) + ->addOption(new Option( + name: 'length', + description: 'The length of the random string for the new key, in bytes.', + requiresValue: true, + default: '32', + )) + ->addOption(new Option( + name: 'prefix', + description: 'Prefix for the new key (either hex2bin or base64).', + requiresValue: true, + default: 'hex2bin', + )) + ->addOption(new Option( + name: 'keep', + description: 'Maximum number of previous keys to retain. Older keys are dropped. 0 means unlimited.', + requiresValue: true, + default: '0', + )); + } + + protected function interact(array &$arguments, array &$options): void + { + $prefix = $this->getUnboundOption('prefix', $options); + + if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) { + $options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required'); + } + + if ($this->hasUnboundOption('force', $options)) { + return; + } + + if (env('encryption.key', '') === '') { + return; + } + + if (CLI::prompt('Rotate encryption key? The current key will be moved to `previousKeys`.', ['n', 'y']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option + } + } + + protected function execute(array $arguments, array $options): int + { + $prefix = $options['prefix']; + + if (! in_array($prefix, self::VALID_PREFIXES, true)) { + CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix)); + + return EXIT_ERROR; + } + + $currentKey = env('encryption.key', ''); + + if ($currentKey === '') { + CLI::error('No existing `encryption.key` to rotate. Run `spark key:generate` first.'); + + return EXIT_ERROR; + } + + if ($options['force'] === false) { + if ($this->isInteractive()) { + CLI::write('Key rotation cancelled.', 'yellow'); + + return EXIT_SUCCESS; + } + + CLI::error('Key rotation aborted: pass --force to rotate the encryption key in non-interactive mode.'); + + return EXIT_ERROR; + } + + $keep = $options['keep']; + + if (! is_string($keep) || ! ctype_digit($keep)) { + CLI::error('The --keep option must be a non-negative integer.'); + + return EXIT_ERROR; + } + + $length = $options['length']; + + if (! is_string($length) || ! ctype_digit($length) || (int) $length < 1) { + CLI::error('The --length option must be a positive integer.'); + + return EXIT_ERROR; + } + + $previousKeys = $this->mergePreviousKeys($currentKey, $this->parsePreviousKeys(), (int) $keep); + + // Write previousKeys first. If the subsequent `key:generate` call fails, + // the worst case is a stale-but-still-decryptable `.env` (the rotated-out + // key is preserved on disk). + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property + + if (! is_file($envFile)) { + CLI::error(sprintf('Cannot rotate: `.env` file not found at %s.', clean_path($envFile))); + + return EXIT_ERROR; + } + + if (! is_writable($envFile)) { + CLI::error(sprintf('Cannot rotate: `.env` file at %s is not writable.', clean_path($envFile))); + + return EXIT_ERROR; + } + + if (! $this->writePreviousKeys($previousKeys, $envFile)) { + // @codeCoverageIgnoreStart + CLI::error(sprintf('Failed to write `encryption.previousKeys` to %s.', clean_path($envFile))); + + return EXIT_ERROR; + // @codeCoverageIgnoreEnd + } + + // Clear `encryption.previousKeys` from all env sources so the DotEnv + // reload triggered by `key:generate` picks up the new value (DotEnv's + // `setVariable()` skips vars that are already set). + putenv('encryption.previousKeys'); + unset($_ENV['encryption.previousKeys']); + service('superglobals')->unsetServer('encryption.previousKeys'); + + $exitCode = $this->callSilently('key:generate', options: [ + 'force' => null, + 'prefix' => $prefix, + 'length' => $length, + ]); + + if ($exitCode !== EXIT_SUCCESS) { + return $exitCode; // @codeCoverageIgnore + } + + $count = count($previousKeys); + + CLI::write(sprintf( + 'Encryption key rotated. %d %s retained for decryption fallback.', + $count, + $count === 1 ? 'previous key' : 'previous keys', + ), 'green'); + CLI::write('Re-encrypt existing data with the new key when ready.', 'yellow'); + + return EXIT_SUCCESS; + } + + /** + * Reads the existing `encryption.previousKeys` from the environment as a + * comma-separated list, ignoring blank entries. + * + * @return list + */ + private function parsePreviousKeys(): array + { + $raw = env('encryption.previousKeys', ''); + + if (! is_string($raw) || $raw === '') { + return []; + } + + return array_values(array_filter( + array_map(trim(...), explode(',', $raw)), + static fn (string $v): bool => $v !== '', + )); + } + + /** + * Prepends the rotated-out key, deduplicates while preserving newest-first order, + * and optionally caps the list length. + * + * @param list $existing + * + * @return list + */ + private function mergePreviousKeys(string $currentKey, array $existing, int $keep): array + { + $merged = [$currentKey, ...$existing]; + $seen = []; + $result = []; + + foreach ($merged as $key) { + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $result[] = $key; + } + + if ($keep > 0) { + $result = array_slice($result, 0, $keep); + } + + return $result; + } + + /** + * Replaces or inserts the `encryption.previousKeys` line in the `.env` file. + * The caller is responsible for ensuring `$envFile` exists and is writable; + * `key:generate` handles the `encryption.key` line. + * + * @param list $previousKeys + */ + private function writePreviousKeys(array $previousKeys, string $envFile): bool + { + $contents = (string) file_get_contents($envFile); + $value = implode(',', $previousKeys); + + // Match an actual setting line, not a substring buried in a comment. The optional + // `export` prefix mirrors what DotEnv accepts. + $previousKeysPattern = '/^(\h*(?:export\h+)?encryption\.previousKeys\h*=\h*)[^\r\n]*$/m'; + + if (preg_match($previousKeysPattern, $contents) === 1) { + $contents = (string) preg_replace($previousKeysPattern, '$1' . $value, $contents, 1); + + return file_put_contents($envFile, $contents) !== false; + } + + // Insert right after the `encryption.key` line so the two stay grouped. + $injected = (string) preg_replace( + '/^(\h*(?:export\h+)?encryption\.key\h*=\h*[^\r\n]*)$/m', + "$1\nencryption.previousKeys = {$value}", + $contents, + 1, + ); + + if ($injected === $contents) { + // Fallback: append to the end. Reachable only when `encryption.key` + // is set via a non-`.env` source (e.g., server config / `putenv()`), + // so the regex cannot find it as a line in the file. + $injected = $contents . "\nencryption.previousKeys = {$value}"; // @codeCoverageIgnore + } + + return file_put_contents($envFile, $injected) !== false; + } +} diff --git a/system/Commands/Generators/CellGenerator.php b/system/Commands/Generators/CellGenerator.php index 04c5bd8adf04..3580f48135ab 100644 --- a/system/Commands/Generators/CellGenerator.php +++ b/system/Commands/Generators/CellGenerator.php @@ -102,6 +102,6 @@ public function run(array $params) $this->generateView($namespace . $viewName, $params); - return 0; + return EXIT_SUCCESS; } } diff --git a/system/Commands/Generators/CommandGenerator.php b/system/Commands/Generators/CommandGenerator.php index a0872f63c2ba..93e1836b47a0 100644 --- a/system/Commands/Generators/CommandGenerator.php +++ b/system/Commands/Generators/CommandGenerator.php @@ -86,6 +86,8 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.command'; $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/Generators/ConfigGenerator.php b/system/Commands/Generators/ConfigGenerator.php index 7b1d5f21ff45..e62abbe69f4a 100644 --- a/system/Commands/Generators/ConfigGenerator.php +++ b/system/Commands/Generators/ConfigGenerator.php @@ -82,6 +82,8 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.config'; $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/Generators/ControllerGenerator.php b/system/Commands/Generators/ControllerGenerator.php index 287f92f87c30..672b344082ca 100644 --- a/system/Commands/Generators/ControllerGenerator.php +++ b/system/Commands/Generators/ControllerGenerator.php @@ -88,6 +88,8 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.controller'; $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/Generators/EntityGenerator.php b/system/Commands/Generators/EntityGenerator.php index 09c350560638..1040b33bbcff 100644 --- a/system/Commands/Generators/EntityGenerator.php +++ b/system/Commands/Generators/EntityGenerator.php @@ -82,5 +82,7 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.entity'; $this->generateClass($params); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Generators/FilterGenerator.php b/system/Commands/Generators/FilterGenerator.php index c723da3afab7..d13c0feff45a 100644 --- a/system/Commands/Generators/FilterGenerator.php +++ b/system/Commands/Generators/FilterGenerator.php @@ -82,5 +82,7 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.filter'; $this->generateClass($params); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Generators/FormRequestGenerator.php b/system/Commands/Generators/FormRequestGenerator.php new file mode 100644 index 000000000000..65e3be3d6754 --- /dev/null +++ b/system/Commands/Generators/FormRequestGenerator.php @@ -0,0 +1,88 @@ + + * + * 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 FormRequest file. + */ +class FormRequestGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:request'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new FormRequest file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:request [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The FormRequest 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 => UserRequest).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Request'; + $this->directory = 'Requests'; + $this->template = 'formrequest.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.request'; + $this->generateClass($params); + + return EXIT_SUCCESS; + } +} diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php index b5e64ec1bce1..f2caf53d93ed 100644 --- a/system/Commands/Generators/MigrationGenerator.php +++ b/system/Commands/Generators/MigrationGenerator.php @@ -93,6 +93,8 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.migration'; $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php index 30b7422238fd..84b20c3a23c3 100644 --- a/system/Commands/Generators/ModelGenerator.php +++ b/system/Commands/Generators/ModelGenerator.php @@ -86,6 +86,8 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.model'; $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/Generators/ScaffoldGenerator.php b/system/Commands/Generators/ScaffoldGenerator.php index 3b1ef7957c9b..727f19298731 100644 --- a/system/Commands/Generators/ScaffoldGenerator.php +++ b/system/Commands/Generators/ScaffoldGenerator.php @@ -115,9 +115,13 @@ public function run(array $params) $class = $params[0] ?? CLI::getSegment(2); // Call those commands! - $this->call('make:controller', array_merge([$class], $controllerOpts, $options)); - $this->call('make:model', array_merge([$class], $modelOpts, $options)); - $this->call('make:migration', array_merge([$class], $options)); - $this->call('make:seeder', array_merge([$class], $options)); + $exit1 = $this->call('make:controller', array_merge([$class], $controllerOpts, $options)); + $exit2 = $this->call('make:model', array_merge([$class], $modelOpts, $options)); + $exit3 = $this->call('make:migration', array_merge([$class], $options)); + $exit4 = $this->call('make:seeder', array_merge([$class], $options)); + + assert(is_int($exit1) && is_int($exit2) && is_int($exit3) && is_int($exit4)); + + return $exit1 | $exit2 | $exit3 | $exit4; } } diff --git a/system/Commands/Generators/SeederGenerator.php b/system/Commands/Generators/SeederGenerator.php index 0549ad840a2d..605bc2c16ead 100644 --- a/system/Commands/Generators/SeederGenerator.php +++ b/system/Commands/Generators/SeederGenerator.php @@ -82,5 +82,7 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.seeder'; $this->generateClass($params); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Generators/TestGenerator.php b/system/Commands/Generators/TestGenerator.php index 47f71294c823..3ff8e39d17a4 100644 --- a/system/Commands/Generators/TestGenerator.php +++ b/system/Commands/Generators/TestGenerator.php @@ -89,6 +89,8 @@ public function run(array $params) $autoload->addNamespace('Tests', ROOTPATH . 'tests'); $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/Generators/TransformerGenerator.php b/system/Commands/Generators/TransformerGenerator.php index 6e9143b5dc48..82837b6c1d87 100644 --- a/system/Commands/Generators/TransformerGenerator.php +++ b/system/Commands/Generators/TransformerGenerator.php @@ -82,6 +82,8 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.transformer'; $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/Generators/ValidationGenerator.php b/system/Commands/Generators/ValidationGenerator.php index d9e3d495a769..64e0c4ff9a34 100644 --- a/system/Commands/Generators/ValidationGenerator.php +++ b/system/Commands/Generators/ValidationGenerator.php @@ -82,5 +82,7 @@ public function run(array $params) $this->classNameLang = 'CLI.generator.className.validation'; $this->generateClass($params); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Generators/Views/command.tpl.php b/system/Commands/Generators/Views/command.tpl.php index bfbdcc4a1741..840b645719d1 100644 --- a/system/Commands/Generators/Views/command.tpl.php +++ b/system/Commands/Generators/Views/command.tpl.php @@ -68,9 +68,11 @@ public function run(array $params) $this->directory = 'Commands'; $this->template = 'command.tpl.php'; - $this->execute($params); + $this->generateClass($params); - // + // your command logic here + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Generators/Views/formrequest.tpl.php b/system/Commands/Generators/Views/formrequest.tpl.php new file mode 100644 index 000000000000..5164cba757e9 --- /dev/null +++ b/system/Commands/Generators/Views/formrequest.tpl.php @@ -0,0 +1,41 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\HTTP\FormRequest; + +class {class} extends FormRequest +{ + /** + * Returns the validation rules that apply to this request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // 'field' => 'required', + ]; + } + + // /** + // * Custom error messages keyed by field.rule. + // * + // * @return array> + // */ + // public function messages(): array + // { + // return []; + // } + + // /** + // * Determines if the current user is authorized to make this request. + // * + // * Defaults to true in FormRequest. Override only when authorization + // * depends on application logic. + // */ + // public function isAuthorized(): bool + // { + // return true; + // } +} diff --git a/system/Commands/Generators/Views/model.tpl.php b/system/Commands/Generators/Views/model.tpl.php index 954404f854d4..455ce7a53600 100644 --- a/system/Commands/Generators/Views/model.tpl.php +++ b/system/Commands/Generators/Views/model.tpl.php @@ -17,6 +17,7 @@ class {class} extends Model protected $protectFields = true; protected $allowedFields = []; + protected bool $throwOnDisallowedFields = false; protected bool $allowEmptyInserts = false; protected bool $updateOnlyChanged = true; diff --git a/system/Commands/Help.php b/system/Commands/Help.php index 76913e8a33fc..c44ffee943ac 100644 --- a/system/Commands/Help.php +++ b/system/Commands/Help.php @@ -13,75 +13,195 @@ namespace CodeIgniter\Commands; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; /** - * CI Help command for the spark script. - * - * Lists the basic usage information for the spark script, - * and provides a way to list help for other commands. + * Displays the basic usage information for a given command. */ -class Help extends BaseCommand +#[Command(name: 'help', description: 'Displays basic usage information.', group: 'CodeIgniter')] +class Help extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'CodeIgniter'; + protected function configure(): void + { + $this->addArgument(new Argument( + name: 'command_name', + description: 'The command name.', + default: $this->getName(), + )); + } - /** - * The Command's name - * - * @var string - */ - protected $name = 'help'; + protected function execute(array $arguments, array $options): int + { + $command = $arguments['command_name']; + assert(is_string($command)); - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Displays basic usage information.'; + $commands = $this->getCommandRunner(); - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'help []'; + if (array_key_exists($command, $commands->getCommands())) { + $commands->getCommand($command, legacy: true)->showHelp(); + + return EXIT_SUCCESS; + } + + if (! $commands->verifyCommand($command, legacy: false)) { + return EXIT_ERROR; + } + + $this->describeHelp($commands->getCommand($command, legacy: false)); + + return EXIT_SUCCESS; + } + + private function describeHelp(AbstractCommand $command): void + { + CLI::write(lang('CLI.helpUsage'), 'yellow'); + + foreach ($command->getUsages() as $usage) { + CLI::write($this->addPadding($usage)); + } + + if ($command->getDescription() !== '') { + CLI::newLine(); + CLI::write(lang('CLI.helpDescription'), 'yellow'); + CLI::write($this->addPadding($command->getDescription())); + } + + if ($command->getAliases() !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpAliases'), 'yellow'); + + foreach ($command->getAliases() as $alias) { + CLI::write($this->addPadding($alias)); + } + } + + $maxPadding = $this->getMaxPadding($command); + + if ($command->getArgumentsDefinition() !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpArguments'), 'yellow'); + + foreach ($command->getArgumentsDefinition() as $argument => $definition) { + $default = ! $definition->required && $this->shouldShowDefault($definition->default) + ? sprintf(' [default: %s]', $this->formatDefaultValue($definition->default)) + : ''; + + CLI::write(sprintf( + '%s%s%s', + CLI::color($this->addPadding($argument, 2, $maxPadding), 'green'), + $definition->description, + CLI::color($default, 'yellow'), + )); + } + } + + if ($command->getOptionsDefinition() !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpOptions'), 'yellow'); + + $hasShortcuts = $command->getShortcuts() !== []; + + foreach ($command->getOptionsDefinition() as $option => $definition) { + $value = ''; + + if ($definition->acceptsValue) { + $value = sprintf('=%s', strtoupper($definition->valueLabel ?? '')); + + if (! $definition->requiresValue) { + $value = sprintf('[%s]', $value); + } + } + + $optionString = sprintf( + '%s--%s%s%s', + $definition->shortcut !== null + ? sprintf('-%s, ', $definition->shortcut) + : ($hasShortcuts ? ' ' : ''), + $option, + $value, + $definition->negation !== null ? sprintf('|--%s', $definition->negation) : '', + ); + + $default = $this->shouldShowOptionDefault($definition) + ? sprintf(' [default: %s]', $this->formatDefaultValue($definition->default)) + : ''; + + CLI::write(sprintf( + '%s%s%s%s', + CLI::color($this->addPadding($optionString, 2, $maxPadding), 'green'), + $definition->description, + CLI::color($default, 'yellow'), + $definition->isArray ? CLI::color(' (multiple values allowed)', 'yellow') : '', + )); + } + } + } + + private function addPadding(string $item, int $before = 2, ?int $max = null): string + { + return str_pad(str_repeat(' ', $before) . $item, $max ?? (strlen($item) + $before)); + } + + private function getMaxPadding(AbstractCommand $command): int + { + $max = 0; + + foreach (array_keys($command->getArgumentsDefinition()) as $argument) { + $max = max($max, strlen($argument)); + } + + $hasShortcuts = $command->getShortcuts() !== []; + + foreach ($command->getOptionsDefinition() as $option => $definition) { + $optionLength = strlen($option) + 2 // Account for the "--" prefix on options. + + ($definition->acceptsValue ? strlen($definition->valueLabel ?? '') + ($definition->requiresValue ? 1 : 3) : 0) // Account for the "=%s" value notation if the option accepts a value. + + ($hasShortcuts ? 4 : 0) // Account for the "-%s, " shortcut notation if shortcuts are present. + + ($definition->negation !== null ? 3 + strlen($definition->negation) : 0); // Account for the "|--no-%s" negation notation if a negation exists for this option. + + $max = max($max, $optionLength); + } + + return $max + 4; // Account for the extra padding around the option/argument. + } /** - * the Command's Arguments - * - * @var array + * Decides whether an option's default value is worth displaying. */ - protected $arguments = [ - 'command_name' => 'The command name [default: "help"]', - ]; + private function shouldShowOptionDefault(Option $definition): bool + { + // Only value-accepting options carry a meaningful default. Plain flags + // and negatable toggles are boolean by nature. + if (! $definition->acceptsValue) { + return false; + } + + return $this->shouldShowDefault($definition->default); + } /** - * the Command's Options + * Whether a default value is concrete enough to display. `null` and the + * empty string are treated as "no default". * - * @var array + * @param list|string|null $value */ - protected $options = []; + private function shouldShowDefault(array|string|null $value): bool + { + return $value !== null && $value !== ''; + } /** - * Displays the help for spark commands. + * @param list|string $value */ - public function run(array $params) + private function formatDefaultValue(array|string $value): string { - $command = array_shift($params); - $command ??= 'help'; - $commands = $this->commands->getCommands(); - - if (! $this->commands->verifyCommand($command, $commands)) { - return; + if (is_array($value)) { + return sprintf('[%s]', implode(', ', array_map($this->formatDefaultValue(...), $value))); } - $class = new $commands[$command]['class']($this->logger, $this->commands); - $class->showHelp(); + return sprintf('"%s"', $value); } } diff --git a/system/Commands/Housekeeping/ClearDebugbar.php b/system/Commands/Housekeeping/ClearDebugbar.php index 281a2c865d6b..87a278291487 100644 --- a/system/Commands/Housekeeping/ClearDebugbar.php +++ b/system/Commands/Housekeeping/ClearDebugbar.php @@ -13,57 +13,29 @@ namespace CodeIgniter\Commands\Housekeeping; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; /** - * ClearDebugbar Command + * Clears all debugbar JSON files. */ -class ClearDebugbar extends BaseCommand +#[Command(name: 'debugbar:clear', description: 'Clears all debugbar JSON files.', group: 'Housekeeping')] +class ClearDebugbar extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Housekeeping'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'debugbar:clear'; - - /** - * The Command's usage - * - * @var string - */ - protected $usage = 'debugbar:clear'; - - /** - * The Command's short description. - * - * @var string - */ - protected $description = 'Clears all debugbar JSON files.'; - - /** - * Actually runs the command. - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { helper('filesystem'); - if (! delete_files(WRITEPATH . 'debugbar', false, true)) { - CLI::error('Error deleting the debugbar JSON files.'); + $path = clean_path(WRITEPATH . 'debugbar'); + + if (! delete_files(WRITEPATH . 'debugbar', htdocs: true)) { + CLI::error(sprintf('Error deleting the debugbar JSON files in "%s".', $path)); return EXIT_ERROR; } - CLI::write('Debugbar cleared.', 'green'); + CLI::write(sprintf('Cleared debugbar JSON files in "%s".', $path), 'green'); return EXIT_SUCCESS; } diff --git a/system/Commands/Housekeeping/ClearLogs.php b/system/Commands/Housekeeping/ClearLogs.php index e416a383cc48..008cb630deac 100644 --- a/system/Commands/Housekeeping/ClearLogs.php +++ b/system/Commands/Housekeeping/ClearLogs.php @@ -13,77 +13,62 @@ namespace CodeIgniter\Commands\Housekeeping; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * ClearLogs command. + * Clears all log files. */ -class ClearLogs extends BaseCommand +#[Command(name: 'logs:clear', description: 'Clears all log files.', group: 'Housekeeping')] +class ClearLogs extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Housekeeping'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'logs:clear'; - - /** - * The Command's short description - * - * @var string - */ - protected $description = 'Clears all log files.'; - - /** - * The Command's usage - * - * @var string - */ - protected $usage = 'logs:clear [option'; - - /** - * The Command's options - * - * @var array - */ - protected $options = [ - '--force' => 'Force delete of all logs files without prompting.', - ]; - - /** - * Actually execute a command. - */ - public function run(array $params) + protected function configure(): void { - $force = array_key_exists('force', $params) || CLI::getOption('force'); + $this->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Forces the clearing of log files without confirmation.', + )); + } + + protected function interact(array &$arguments, array &$options): void + { + if ($this->hasUnboundOption('force', $options)) { + return; + } + + if (CLI::prompt(sprintf('Delete all log files in %s?', clean_path(WRITEPATH . 'logs')), ['n', 'y']) === 'n') { + return; + } + + $options['force'] = null; // simulate the presence of the --force option + } + + protected function execute(array $arguments, array $options): int + { + if ($options['force'] === false) { + if ($this->isInteractive()) { + CLI::write('Log deletion cancelled.', 'yellow'); - if (! $force && CLI::prompt('Are you sure you want to delete the logs?', ['n', 'y']) === 'n') { - CLI::error('Deleting logs aborted.'); + return EXIT_SUCCESS; + } - // @todo to re-add under non-interactive mode - // CLI::error('If you want, use the "--force" option to force delete all log files.'); + CLI::error('Log deletion aborted: pass --force to delete log files in non-interactive mode.'); return EXIT_ERROR; } helper('filesystem'); - if (! delete_files(WRITEPATH . 'logs', false, true)) { - CLI::error('Error in deleting the logs files.'); + if (! delete_files(WRITEPATH . 'logs', htdocs: true)) { + CLI::error(sprintf('Failed to delete log files in %s.', clean_path(WRITEPATH . 'logs'))); return EXIT_ERROR; } - CLI::write('Logs cleared.', 'green'); + CLI::write(sprintf('Log files in %s cleared.', clean_path(WRITEPATH . 'logs')), 'green'); return EXIT_SUCCESS; } diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php index 8761e2bb7bbc..a4a011f30a87 100644 --- a/system/Commands/ListCommands.php +++ b/system/Commands/ListCommands.php @@ -13,134 +13,120 @@ namespace CodeIgniter\Commands; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * CI Help command for the spark script. - * - * Lists the basic usage information for the spark script, - * and provides a way to list help for other commands. + * Lists the available commands. */ -class ListCommands extends BaseCommand +#[Command(name: 'list', description: 'Lists the available commands.', group: 'CodeIgniter')] +class ListCommands extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'CodeIgniter'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'list'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Lists the available commands.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'list'; - - /** - * the Command's Arguments - * - * @var array - */ - protected $arguments = []; - - /** - * the Command's Options - * - * @var array - */ - protected $options = [ - '--simple' => 'Prints a list of the commands with no other info', - ]; - - /** - * Displays the help for the spark cli script itself. - * - * @return int - */ - public function run(array $params) + protected function configure(): void { - $commands = $this->commands->getCommands(); - ksort($commands); - - // Check for 'simple' format - return array_key_exists('simple', $params) || CLI::getOption('simple') === true - ? $this->listSimple($commands) - : $this->listFull($commands); + $this->addOption(new Option( + name: 'simple', + description: 'Prints a list of commands with no other information.', + )); } - /** - * Lists the commands with accompanying info. - * - * @return int - */ - protected function listFull(array $commands) + protected function execute(array $arguments, array $options): int { - // Sort into buckets by group - $groups = []; + if ($options['simple'] === true) { + return $this->describeCommandsSimple(); + } - foreach ($commands as $title => $command) { - if (! isset($groups[$command['group']])) { - $groups[$command['group']] = []; - } + return $this->describeCommandsDetailed(); + } - $groups[$command['group']][$title] = $command; + private function describeCommandsSimple(): int + { + // Legacy takes precedence on key collision so the listing reflects the + // command that would actually be invoked. + $runner = $this->getCommandRunner(); + $commands = array_keys( + $runner->getCommands() + $runner->getModernCommands() + $runner->getCommandAliases(), + ); + sort($commands); + + foreach ($commands as $command) { + CLI::write($command); } - $length = max(array_map(strlen(...), array_keys($commands))); + return EXIT_SUCCESS; + } - ksort($groups); + private function describeCommandsDetailed(): int + { + CLI::write(lang('CLI.helpUsage'), 'yellow'); + CLI::write($this->addPadding('command [options] [--] [arguments]')); - // Display it all... - foreach ($groups as $group => $commands) { - CLI::write($group, 'yellow'); + $entries = []; + $maxPad = 0; + + // Legacy takes precedence on key collision so the listing reflects the + // command that would actually be invoked. + $runner = $this->getCommandRunner(); + $modern = $runner->getModernCommands(); + $all = $runner->getCommands() + $modern; - foreach ($commands as $name => $command) { - $name = $this->setPad($name, $length, 2, 2); - $output = CLI::color($name, 'green'); + foreach ($all as $command => $details) { + $maxPad = max($maxPad, strlen($command) + 4); - if (isset($command['description'])) { - $output .= CLI::wrap($command['description'], 125, strlen($name)); - } + $entries[] = [$details['group'], $command, $details['description']]; + } + + // Aliases are listed as their own rows under the group of the command they resolve to. + foreach ($runner->getCommandAliases() as $alias => $canonical) { + $maxPad = max($maxPad, strlen($alias) + 4); + + $entries[] = [$modern[$canonical]['group'], $alias, lang('CLI.commandAlias', [$canonical])]; + } - CLI::write($output); + usort($entries, static function (array $a, array $b): int { + $cmp = strcmp($a[0], $b[0]); + + if ($cmp !== 0) { + return $cmp; } - if ($group !== array_key_last($groups)) { + return strcmp($a[1], $b[1]); + }); + + $groups = []; + + foreach ($entries as [$group, $command, $description]) { + $groups[$group][] = [$command, $description]; + } + + CLI::newLine(); + CLI::write(lang('CLI.helpAvailableCommands'), 'yellow'); + + $firstGroup = array_key_first($groups); + + foreach ($groups as $group => $commands) { + if ($group !== $firstGroup) { CLI::newLine(); } + + CLI::write($group, 'yellow'); + + foreach ($commands as $command) { + CLI::write(sprintf( + '%s%s', + CLI::color($this->addPadding($command[0], 2, $maxPad), 'green'), + CLI::wrap($command[1], 0, $maxPad), + )); + } } return EXIT_SUCCESS; } - /** - * Lists the commands only. - * - * @return int - */ - protected function listSimple(array $commands) + private function addPadding(string $item, int $before = 2, ?int $max = null): string { - foreach (array_keys($commands) as $title) { - CLI::write($title); - } - - return EXIT_SUCCESS; + return str_pad(str_repeat(' ', $before) . $item, $max ?? (strlen($item) + $before)); } } diff --git a/system/Commands/Server/Serve.php b/system/Commands/Server/Serve.php index bbc06b665e62..d7f7e9ee7fc4 100644 --- a/system/Commands/Server/Serve.php +++ b/system/Commands/Server/Serve.php @@ -13,98 +13,57 @@ namespace CodeIgniter\Commands\Server; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * Launch the PHP development server. + * Launches the CodeIgniter PHP-Development Server. + * + * @codeCoverageIgnore */ -class Serve extends BaseCommand +#[Command(name: 'serve', description: 'Launches the CodeIgniter PHP-Development Server.', group: 'CodeIgniter')] +class Serve extends AbstractCommand { /** - * Group - * - * @var string - */ - protected $group = 'CodeIgniter'; - - /** - * Name - * - * @var string - */ - protected $name = 'serve'; - - /** - * Description - * - * @var string - */ - protected $description = 'Launches the CodeIgniter PHP-Development Server.'; - - /** - * Usage - * - * @var string - */ - protected $usage = 'serve'; - - /** - * Arguments - * - * @var array - */ - protected $arguments = []; - - /** - * The current port offset. - * - * @var int + * The number of times to retry if the port is already in use. */ - protected $portOffset = 0; + private const RETRIES = 10; - /** - * The max number of ports to attempt to serve from - * - * @var int - */ - protected $tries = 10; - - /** - * Options - * - * @var array - */ - protected $options = [ - '--php' => 'The PHP Binary [default: "PHP_BINARY"]', - '--host' => 'The HTTP Host [default: "localhost"]', - '--port' => 'The HTTP Host Port [default: "8080"]', - ]; + protected function configure(): void + { + $this + ->addOption(new Option(name: 'php', description: 'The PHP binary to use.', requiresValue: true, default: PHP_BINARY)) + ->addOption(new Option(name: 'host', description: 'The host to serve on.', requiresValue: true, default: 'localhost')) + ->addOption(new Option(name: 'port', description: 'The port to serve on.', requiresValue: true, default: '8080')); + } - /** - * Run the server. - * - * @codeCoverageIgnore - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { - $php = CLI::getOption('php') ?? PHP_BINARY; - $host = CLI::getOption('host') ?? 'localhost'; - $port = (int) (CLI::getOption('port') ?? 8080) + $this->portOffset; + $basePort = (int) $options['port']; + $status = EXIT_SUCCESS; - CLI::write('CodeIgniter development server started on http://' . $host . ':' . $port, 'green'); - CLI::write('Press Control-C to stop.'); + for ($offset = 0; $offset <= self::RETRIES; $offset++) { + $port = $basePort + $offset; - passthru( - $this->buildServeCommand($php, $host, $port, FCPATH, SYSTEMPATH . 'rewrite.php'), - $status, - ); + CLI::write(sprintf('CodeIgniter development server started on http://%s:%s', $options['host'], $port), 'green'); + CLI::write('Press Control-C to stop.'); + CLI::newLine(); + + passthru( + $this->buildServeCommand($options['php'], $options['host'], $port, FCPATH, SYSTEMPATH . 'rewrite.php'), + $status, + ); - if ($status !== EXIT_SUCCESS && $this->portOffset < $this->tries) { - $this->portOffset++; + if ($status === EXIT_SUCCESS) { + return $status; + } - $this->run($params); + CLI::newLine(); } + + return $status; } /** diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php index ca7a1fb20230..2bcc2b60c377 100644 --- a/system/Commands/Translation/LocalizationFinder.php +++ b/system/Commands/Translation/LocalizationFinder.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Commands\Translation; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Helpers\Array\ArrayHelper; use Config\App; use Locale; @@ -23,73 +25,85 @@ use SplFileInfo; /** - * @see \CodeIgniter\Commands\Translation\LocalizationFinderTest + * Finds and saves available phrases to translate. */ -class LocalizationFinder extends BaseCommand +#[Command( + name: 'lang:find', + description: 'Find and save available phrases to translate.', + group: 'Translation', +)] +class LocalizationFinder extends AbstractCommand { - protected $group = 'Translation'; - protected $name = 'lang:find'; - protected $description = 'Find and save available phrases to translate.'; - protected $usage = 'lang:find [options]'; - protected $arguments = []; - protected $options = [ - '--locale' => 'Specify locale (en, ru, etc.) to save files.', - '--dir' => 'Directory to search for translations relative to APPPATH.', - '--show-new' => 'Show only new translations in table. Does not write to files.', - '--verbose' => 'Output detailed information.', - ]; + private string $languagePath; - /** - * Flag for output detailed information - */ - private bool $verbose = false; + protected function configure(): void + { + $this + ->addOption(new Option( + name: 'locale', + description: 'Specify locale (en, ru, etc.) to save files.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'dir', + description: 'Directory to search for translations relative to APPPATH.', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'show-new', + description: 'Show only new translations in table. Does not write to files.', + )) + ->addOption(new Option( + name: 'verbose', + description: 'Output detailed information.', + )); + } - /** - * Flag for showing only translations, without saving - */ - private bool $showNew = false; + protected function execute(array $arguments, array $options): int + { + $locale = $options['locale']; + assert(is_string($locale)); - private string $languagePath; + $dir = $options['dir']; + assert(is_string($dir)); - public function run(array $params) - { - $this->verbose = array_key_exists('verbose', $params); - $this->showNew = array_key_exists('show-new', $params); - $optionLocale = $params['locale'] ?? null; - $optionDir = $params['dir'] ?? null; - $currentLocale = Locale::getDefault(); - $currentDir = APPPATH; - $this->languagePath = $currentDir . 'Language'; - - if (ENVIRONMENT === 'testing') { - $currentDir = SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR; - $this->languagePath = SUPPORTPATH . 'Language'; - } + $currentLocale = Locale::getDefault(); + + ['currentDir' => $currentDir, 'languagePath' => $this->languagePath] = $this->resolvePaths(); + + if ($locale !== '') { + $supportedLocales = config(App::class)->supportedLocales; - if (is_string($optionLocale)) { - if (! in_array($optionLocale, config(App::class)->supportedLocales, true)) { + if (! in_array($locale, $supportedLocales, true)) { CLI::error( - 'Error: "' . $optionLocale . '" is not supported. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), + sprintf( + 'Error: "%s" is not supported. Supported locales: %s', + $locale, + implode(', ', $supportedLocales), + ), + 'light_gray', + 'red', ); return EXIT_USER_INPUT; } - $currentLocale = $optionLocale; + $currentLocale = $locale; } - if (is_string($optionDir)) { - $tempCurrentDir = realpath($currentDir . $optionDir); + if ($dir !== '') { + $tempCurrentDir = realpath($currentDir . $dir); if ($tempCurrentDir === false) { - CLI::error('Error: Directory must be located in "' . $currentDir . '"'); + CLI::error(sprintf('Error: Directory must be located in "%s"', $currentDir), 'light_gray', 'red'); return EXIT_USER_INPUT; } - if ($this->isSubDirectory($tempCurrentDir, $this->languagePath)) { - CLI::error('Error: Directory "' . $this->languagePath . '" restricted to scan.'); + if ($this->isSubdirectory($tempCurrentDir, $this->languagePath)) { + CLI::error(sprintf('Error: Directory "%s" restricted to scan.', $this->languagePath), 'light_gray', 'red'); return EXIT_USER_INPUT; } @@ -99,18 +113,42 @@ public function run(array $params) $this->process($currentDir, $currentLocale); - CLI::write('All operations done!'); + CLI::write('All operations done!', 'green'); return EXIT_SUCCESS; } + /** + * Resolves the directory to scan and the directory that holds the language + * files, swapping in the test fixtures under the testing environment. + * + * @return array{currentDir: string, languagePath: string} + */ + private function resolvePaths(): array + { + if (service('environment')->isTesting()) { + return [ + 'currentDir' => SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR, + 'languagePath' => SUPPORTPATH . 'Language', + ]; + } + + return [ + 'currentDir' => APPPATH, + 'languagePath' => APPPATH . 'Language', + ]; + } + private function process(string $currentDir, string $currentLocale): void { + $showNew = $this->getValidatedOption('show-new') === true; + $verbose = $this->getValidatedOption('verbose') === true; + $tableRows = []; $countNewKeys = 0; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($currentDir)); - $files = iterator_to_array($iterator, true); + $files = iterator_to_array($iterator); ksort($files); [ @@ -121,10 +159,7 @@ private function process(string $currentDir, string $currentLocale): void ksort($foundLanguageKeys); - $languageDiff = []; - $languageFoundGroups = array_unique(array_keys($foundLanguageKeys)); - - foreach ($languageFoundGroups as $langFileName) { + foreach ($foundLanguageKeys as $langFileName => $foundKeys) { $languageStoredKeys = []; $languageFilePath = $this->languagePath . DIRECTORY_SEPARATOR . $currentLocale . DIRECTORY_SEPARATOR . $langFileName . '.php'; @@ -137,38 +172,38 @@ private function process(string $currentDir, string $currentLocale): void // are not new and must not be re-reported or written. $resolvedKeys = $this->findResolvedTranslations($langFileName, $currentLocale); - $languageDiff = ArrayHelper::recursiveDiff($foundLanguageKeys[$langFileName], $resolvedKeys); + $languageDiff = ArrayHelper::recursiveDiff($foundKeys, $resolvedKeys); $countNewKeys += ArrayHelper::recursiveCount($languageDiff); - if ($this->showNew) { + if ($showNew) { $tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows); } else { $newLanguageKeys = array_replace_recursive($languageDiff, $languageStoredKeys); if ($languageDiff !== []) { if (file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys)) === false) { - $this->writeIsVerbose('Lang file ' . $langFileName . ' (error write).', 'red'); + $this->writeIsVerbose(sprintf('Lang file %s (error write).', $langFileName), 'red'); } else { - $this->writeIsVerbose('Lang file "' . $langFileName . '" successful updated!', 'green'); + $this->writeIsVerbose(sprintf('Lang file "%s" successful updated!', $langFileName), 'green'); } } } } - if ($this->showNew && $tableRows !== []) { + if ($showNew && $tableRows !== []) { sort($tableRows); CLI::table($tableRows, ['File', 'Key']); } - if (! $this->showNew && $countNewKeys > 0) { + if (! $showNew && $countNewKeys > 0) { CLI::write('Note: You need to run your linting tool to fix coding standards issues.', 'white', 'red'); } - $this->writeIsVerbose('Files found: ' . $countFiles); - $this->writeIsVerbose('New translates found: ' . $countNewKeys); - $this->writeIsVerbose('Bad translates found: ' . count($badLanguageKeys)); + $this->writeIsVerbose(sprintf('Files found: %d', $countFiles)); + $this->writeIsVerbose(sprintf('New translates found: %d', $countNewKeys)); + $this->writeIsVerbose(sprintf('Bad translates found: %d', count($badLanguageKeys))); - if ($this->verbose && $badLanguageKeys !== []) { + if ($verbose && $badLanguageKeys !== []) { $tableBadRows = []; foreach ($badLanguageKeys as $value) { @@ -211,19 +246,13 @@ private function findResolvedTranslations(string $langFileName, string $currentL } /** - * @param SplFileInfo|string $file - * - * @return array + * @return array{foundLanguageKeys: array, badLanguageKeys: list} */ - private function findTranslationsInFile($file): array + private function findTranslationsInFile(SplFileInfo $file): array { $foundLanguageKeys = []; $badLanguageKeys = []; - if (is_string($file) && is_file($file)) { - $file = new SplFileInfo($file); - } - $fileContent = file_get_contents($file->getRealPath()); preg_match_all('/lang\(\'([._a-z0-9\-]+)\'\)/ui', $fileContent, $matches); @@ -266,13 +295,16 @@ private function findTranslationsInFile($file): array private function isIgnoredFile(SplFileInfo $file): bool { - if ($file->isDir() || $this->isSubDirectory($file->getRealPath(), $this->languagePath)) { + if ($file->isDir() || $this->isSubdirectory($file->getRealPath(), $this->languagePath)) { return true; } return $file->getExtension() !== 'php'; } + /** + * @param array $language + */ private function templateFile(array $language = []): string { if ($language !== []) { @@ -337,6 +369,10 @@ private function replaceArraySyntax(string $code): string /** * Create multidimensional array from another keys + * + * @param list $fromKeys + * + * @return array */ private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array { @@ -356,6 +392,10 @@ private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): /** * Convert multi arrays to specific CLI table rows (flat array) + * + * @param array $array + * + * @return list */ private function arrayToTableRows(string $langFileName, array $array): array { @@ -381,12 +421,12 @@ private function arrayToTableRows(string $langFileName, array $array): array */ private function writeIsVerbose(string $text = '', ?string $foreground = null, ?string $background = null): void { - if ($this->verbose) { + if ($this->getValidatedOption('verbose') === true) { CLI::write($text, $foreground, $background); } } - private function isSubDirectory(string $directory, string $rootDirectory): bool + private function isSubdirectory(string $directory, string $rootDirectory): bool { return 0 === strncmp($directory, $rootDirectory, strlen($directory)); } @@ -407,7 +447,7 @@ private function findLanguageKeysInFiles(array $files): array continue; } - $this->writeIsVerbose('File found: ' . mb_substr($file->getRealPath(), mb_strlen(APPPATH))); + $this->writeIsVerbose(sprintf('File found: %s', mb_substr($file->getRealPath(), mb_strlen(APPPATH)))); $countFiles++; $findInFile = $this->findTranslationsInFile($file); diff --git a/system/Commands/Translation/LocalizationSync.php b/system/Commands/Translation/LocalizationSync.php index f54df45c364c..68dd1c09ccc9 100644 --- a/system/Commands/Translation/LocalizationSync.php +++ b/system/Commands/Translation/LocalizationSync.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Commands\Translation; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Exceptions\LogicException; use Config\App; use ErrorException; @@ -25,79 +27,107 @@ use SplFileInfo; /** - * @see \CodeIgniter\Commands\Translation\LocalizationSyncTest + * Synchronizes translation files from one language to another. */ -class LocalizationSync extends BaseCommand +#[Command( + name: 'lang:sync', + description: 'Synchronize translation files from one language to another.', + group: 'Translation', +)] +class LocalizationSync extends AbstractCommand { - protected $group = 'Translation'; - protected $name = 'lang:sync'; - protected $description = 'Synchronize translation files from one language to another.'; - protected $usage = 'lang:sync [options]'; - protected $arguments = []; - protected $options = [ - '--locale' => 'The original locale (en, ru, etc.).', - '--target' => 'Target locale (en, ru, etc.).', - ]; private string $languagePath; - public function run(array $params) + protected function configure(): void { - $optionTargetLocale = ''; - $optionLocale = $params['locale'] ?? Locale::getDefault(); + $this + ->addOption(new Option( + name: 'locale', + description: 'The original locale (en, ru, etc.).', + requiresValue: true, + default: '', + )) + ->addOption(new Option( + name: 'target', + description: 'Target locale (en, ru, etc.).', + requiresValue: true, + default: '', + )); + } + + protected function execute(array $arguments, array $options): int + { + $locale = $options['locale']; + assert(is_string($locale)); + + $target = $options['target']; + assert(is_string($target)); + $this->languagePath = APPPATH . 'Language'; + $supportedLocales = config(App::class)->supportedLocales; - if (isset($params['target']) && $params['target'] !== '') { - $optionTargetLocale = $params['target']; + if ($locale === '') { + $locale = Locale::getDefault(); } - if (! in_array($optionLocale, config(App::class)->supportedLocales, true)) { - CLI::error( - 'Error: "' . $optionLocale . '" is not supported. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), - ); - - return EXIT_USER_INPUT; + if (! in_array($locale, $supportedLocales, true)) { + return $this->errorUnsupportedLocale($locale); } - if ($optionTargetLocale === '') { + if ($target === '') { CLI::error( - 'Error: "--target" is not configured. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), + sprintf( + 'Error: "--target" is not configured. Supported locales: %s', + implode(', ', $supportedLocales), + ), + 'light_gray', + 'red', ); return EXIT_USER_INPUT; } - if (! in_array($optionTargetLocale, config(App::class)->supportedLocales, true)) { - CLI::error( - 'Error: "' . $optionTargetLocale . '" is not supported. Supported locales: ' - . implode(', ', config(App::class)->supportedLocales), - ); - - return EXIT_USER_INPUT; + if (! in_array($target, $supportedLocales, true)) { + return $this->errorUnsupportedLocale($target); } - if ($optionTargetLocale === $optionLocale) { - CLI::error( - 'Error: You cannot have the same values for "--target" and "--locale".', - ); + if ($target === $locale) { + CLI::error('Error: You cannot have the same values for "--target" and "--locale".', 'light_gray', 'red'); return EXIT_USER_INPUT; } - if (ENVIRONMENT === 'testing') { + if (service('environment')->isTesting()) { $this->languagePath = SUPPORTPATH . 'Language'; } - if ($this->process($optionLocale, $optionTargetLocale) === EXIT_ERROR) { + if ($this->process($locale, $target) === EXIT_ERROR) { return EXIT_ERROR; } - CLI::write('All operations done!'); + CLI::write('All operations done!', 'green'); return EXIT_SUCCESS; } + /** + * Writes the unsupported-locale error and returns the user-input exit code. + */ + private function errorUnsupportedLocale(string $locale): int + { + CLI::error( + sprintf( + 'Error: "%s" is not supported. Supported locales: %s', + $locale, + implode(', ', config(App::class)->supportedLocales), + ), + 'light_gray', + 'red', + ); + + return EXIT_USER_INPUT; + } + private function process(string $originalLocale, string $targetLocale): int { $originalLocaleDir = $this->languagePath . DIRECTORY_SEPARATOR . $originalLocale; @@ -105,7 +135,9 @@ private function process(string $originalLocale, string $targetLocale): int if (! is_dir($originalLocaleDir)) { CLI::error( - 'Error: The "' . clean_path($originalLocaleDir) . '" directory was not found.', + sprintf('Error: The "%s" directory was not found.', clean_path($originalLocaleDir)), + 'light_gray', + 'red', ); return EXIT_ERROR; @@ -118,7 +150,9 @@ private function process(string $originalLocale, string $targetLocale): int } } catch (ErrorException $e) { CLI::error( - 'Error: The target directory "' . clean_path($targetLocaleDir) . '" cannot be accessed.', + sprintf('Error: The target directory "%s" cannot be accessed.', clean_path($targetLocaleDir)), + 'light_gray', + 'red', ); return EXIT_ERROR; @@ -131,10 +165,10 @@ private function process(string $originalLocale, string $targetLocale): int ), ); - /** @var array $files */ - $files = iterator_to_array($iterator, true); + $files = iterator_to_array($iterator); ksort($files); + /** @var SplFileInfo $originalLanguageFile */ foreach ($files as $originalLanguageFile) { if ($originalLanguageFile->getExtension() !== 'php') { continue; @@ -191,7 +225,10 @@ private function mergeLanguageKeys(array $originalLanguageKeys, array $targetLan $mergedLanguageKeys[$key] = $this->mergeLanguageKeys($value, $targetLanguageKeys[$key], $placeholderValue); } else { - throw new LogicException('Value for the key "' . $placeholderValue . '" is of the wrong type. Only "array" or "string" is allowed.'); + throw new LogicException(sprintf( + 'Value for the key "%s" is of the wrong type. Only "array" or "string" is allowed.', + $placeholderValue, + )); } } diff --git a/system/Commands/Utilities/Environment.php b/system/Commands/Utilities/Environment.php index 778e1833becb..44a0a6866ffe 100644 --- a/system/Commands/Utilities/Environment.php +++ b/system/Commands/Utilities/Environment.php @@ -89,7 +89,7 @@ public function run(array $params) CLI::write(sprintf('Your environment is currently set as %s.', CLI::color(service('superglobals')->server('CI_ENVIRONMENT', ENVIRONMENT), 'green'))); CLI::newLine(); - return EXIT_ERROR; + return EXIT_SUCCESS; } $env = strtolower(array_shift($params)); diff --git a/system/Commands/Utilities/Namespaces.php b/system/Commands/Utilities/Namespaces.php index 8dda8ce0732e..498e4deba0dd 100644 --- a/system/Commands/Utilities/Namespaces.php +++ b/system/Commands/Utilities/Namespaces.php @@ -89,6 +89,8 @@ public function run(array $params) ]; CLI::table($tbody, $thead); + + return EXIT_SUCCESS; } private function outputAllNamespaces(array $params): array diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php index 13ffa96eafc6..0fc6ef44efc8 100644 --- a/system/Commands/Utilities/Publish.php +++ b/system/Commands/Utilities/Publish.php @@ -86,9 +86,11 @@ public function run(array $params) CLI::write(lang('Publisher.publishMissingNamespace', [$directory, $namespace])); } - return; + return EXIT_ERROR; } + $exit = EXIT_SUCCESS; + foreach ($publishers as $publisher) { if ($publisher->publish()) { CLI::write(lang('Publisher.publishSuccess', [ @@ -96,18 +98,24 @@ public function run(array $params) count($publisher->getPublished()), $publisher->getDestination(), ]), 'green'); - } else { - CLI::error(lang('Publisher.publishFailure', [ - $publisher::class, - $publisher->getDestination(), - ]), 'light_gray', 'red'); - foreach ($publisher->getErrors() as $file => $exception) { - CLI::write($file); - CLI::error($exception->getMessage()); - CLI::newLine(); - } + continue; } + + CLI::error(lang('Publisher.publishFailure', [ + $publisher::class, + $publisher->getDestination(), + ]), 'light_gray', 'red'); + + foreach ($publisher->getErrors() as $file => $exception) { + CLI::write($file); + CLI::error($exception->getMessage()); + CLI::newLine(); + } + + $exit = EXIT_ERROR; } + + return $exit; } } diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php index a42ff0197525..6ad32e18468b 100644 --- a/system/Commands/Utilities/Routes.php +++ b/system/Commands/Utilities/Routes.php @@ -84,15 +84,6 @@ public function run(array $params) { $sortByHandler = array_key_exists('sort-by-handler', $params); - if (! $sortByHandler && array_key_exists('h', $params)) { - // @todo to remove support in v4.8.0 - // Support -h as a shortcut but print a warning that it is not the intended use of -h. - CLI::write('Warning: -h will be used as shortcut for --help in v4.8.0. Please use --sort-by-handler to sort by handler.', 'yellow'); - CLI::newLine(); - - $sortByHandler = true; - } - $host = $params['host'] ?? null; // Set HTTP_HOST @@ -203,10 +194,10 @@ public function run(array $params) CLI::table($tbody, $thead); - $this->showRequiredFilters(); + return $this->showRequiredFilters(); } - private function showRequiredFilters(): void + private function showRequiredFilters(): int { $filterCollector = new FilterCollector(); @@ -227,5 +218,7 @@ private function showRequiredFilters(): void } CLI::write(' Required After Filters: ' . implode(', ', $filters)); + + return EXIT_SUCCESS; } } diff --git a/system/Commands/Utilities/Routes/FilterCollector.php b/system/Commands/Utilities/Routes/FilterCollector.php index ec1219debe1a..98963bba7d74 100644 --- a/system/Commands/Utilities/Routes/FilterCollector.php +++ b/system/Commands/Utilities/Routes/FilterCollector.php @@ -37,7 +37,8 @@ public function __construct( } /** - * Returns filters for the URI + * Returns filters for the URI based on the HTTP method. + * The HTTP method is compared case-sensitively. * * @param string $method HTTP verb like `GET`,`POST` or `CLI`. * @param string $uri URI path to find filters for @@ -46,25 +47,8 @@ public function __construct( */ public function get(string $method, string $uri): array { - if ($method === strtolower($method)) { - @trigger_error( - 'Passing lowercase HTTP method "' . $method . '" is deprecated.' - . ' Use uppercase HTTP method like "' . strtoupper($method) . '".', - E_USER_DEPRECATED, - ); - } - - /** - * @deprecated 4.5.0 - * @TODO Remove this in the future. - */ - $method = strtoupper($method); - if ($method === 'CLI') { - return [ - 'before' => [], - 'after' => [], - ]; + return ['before' => [], 'after' => []]; } $request = service('incomingrequest', null, false); @@ -79,7 +63,8 @@ public function get(string $method, string $uri): array } /** - * Returns filter classes for the URI + * Returns filter classes for the URI based on the HTTP method. + * The HTTP method is compared case-sensitively. * * @param string $method HTTP verb like `GET`,`POST` or `CLI`. * @param string $uri URI path to find filters for @@ -88,25 +73,8 @@ public function get(string $method, string $uri): array */ public function getClasses(string $method, string $uri): array { - if ($method === strtolower($method)) { - @trigger_error( - 'Passing lowercase HTTP method "' . $method . '" is deprecated.' - . ' Use uppercase HTTP method like "' . strtoupper($method) . '".', - E_USER_DEPRECATED, - ); - } - - /** - * @deprecated 4.5.0 - * @TODO Remove this in the future. - */ - $method = strtoupper($method); - if ($method === 'CLI') { - return [ - 'before' => [], - 'after' => [], - ]; + return ['before' => [], 'after' => []]; } $request = service('incomingrequest', null, false); diff --git a/system/Commands/Utilities/Routes/PlaceholderSampleGenerator.php b/system/Commands/Utilities/Routes/PlaceholderSampleGenerator.php new file mode 100644 index 000000000000..3f77c09fb6a3 --- /dev/null +++ b/system/Commands/Utilities/Routes/PlaceholderSampleGenerator.php @@ -0,0 +1,382 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +/** + * Generates a sample string that matches a simple regular expression pattern. + * + * Supports the common fragments seen in custom route placeholders: character + * classes (``[A-Z]``, ``[0-9a-f]``), shorthand escapes (``\d``, ``\w``), + * quantifiers (``{n}``, ``{n,m}``, ``+``, ``*``, ``?``), literals, and + * top-level alternation (``a|b``). Anchors (``^``, ``$``) are tolerated and + * ignored. Anything more exotic (lookarounds, backreferences, nested groups + * with quantifiers, POSIX classes) causes the generator to bail out by + * returning ``null``. + * + * The generated candidate is validated against the original pattern with + * ``preg_match`` before being returned, so callers never receive a non-matching + * sample even when the parser is too permissive for a given edge case. + * + * @see \CodeIgniter\Commands\Utilities\Routes\PlaceholderSampleGeneratorTest + */ +final class PlaceholderSampleGenerator +{ + private string $pattern; + private int $position = 0; + private int $length = 0; + + /** + * Returns a sample string matching the regular expression ``$pattern``, or + * ``null`` when the pattern uses features this generator cannot reverse. + */ + public function generate(string $pattern): ?string + { + $this->pattern = $pattern; + $this->position = 0; + $this->length = strlen($pattern); + + try { + $sample = $this->parseAlternation(); + } catch (UnsupportedPatternException) { + return null; + } + + if ($this->position !== $this->length) { + return null; + } + + if (@preg_match('#^(?:' . $pattern . ')$#', $sample) !== 1) { + return null; + } + + return $sample; + } + + /** + * Parses a (possibly alternated) sequence and returns the first branch. + */ + private function parseAlternation(): string + { + $branch = $this->parseSequence(); + + if ($this->position < $this->length && $this->pattern[$this->position] === '|') { + // Skip the remaining branches; the first one is enough. + while ($this->position < $this->length && $this->pattern[$this->position] === '|') { + $this->position++; + $this->parseSequence(); + } + } + + return $branch; + } + + private function parseSequence(): string + { + $output = ''; + + while ($this->position < $this->length) { + $char = $this->pattern[$this->position]; + + if ($char === '|' || $char === ')') { + break; + } + + $atom = $this->parseAtom(); + + [$minRepeat] = $this->parseQuantifier(); + + $output .= str_repeat($atom, $minRepeat); + } + + return $output; + } + + private function parseAtom(): string + { + $char = $this->pattern[$this->position]; + + if ($char === '^' || $char === '$') { + $this->position++; + + return ''; + } + + if ($char === '.') { + $this->position++; + + return 'a'; + } + + if ($char === '\\') { + return $this->parseEscape(); + } + + if ($char === '[') { + return $this->parseCharacterClass(); + } + + if ($char === '(') { + return $this->parseGroup(); + } + + // Unsupported metacharacters outside of the ones handled above. + if (in_array($char, ['+', '*', '?', '{', '}', ']'], true)) { + throw new UnsupportedPatternException(); + } + + $this->position++; + + return $char; + } + + private function parseEscape(): string + { + $this->position++; + + if ($this->position >= $this->length) { + throw new UnsupportedPatternException(); + } + + $char = $this->pattern[$this->position]; + $this->position++; + + return match ($char) { + 'd' => '0', + 'D' => 'a', + 'w' => 'a', + 'W' => '-', + 's' => ' ', + 'S' => 'a', + default => $char, + }; + } + + private function parseCharacterClass(): string + { + $this->position++; + $negated = false; + + if ($this->position < $this->length && $this->pattern[$this->position] === '^') { + $negated = true; + $this->position++; + } + + $allowed = []; + $first = true; + + while ($this->position < $this->length && ($this->pattern[$this->position] !== ']' || $first)) { + $first = false; + $char = $this->pattern[$this->position]; + + if ($char === '\\') { + $this->position++; + + if ($this->position >= $this->length) { + throw new UnsupportedPatternException(); + } + + $escaped = $this->pattern[$this->position]; + $this->position++; + + $allowed = [...$allowed, ...$this->expandEscapedClassChar($escaped)]; + + continue; + } + + // Range a-z + if ( + $this->position < $this->length - 2 + && $this->pattern[$this->position + 1] === '-' + && $this->pattern[$this->position + 2] !== ']' + ) { + $start = $char; + + $end = $this->pattern[$this->position + 2]; + $this->position += 3; + + if (ord($start) > ord($end)) { + throw new UnsupportedPatternException(); + } + + for ($code = ord($start); $code <= ord($end); $code++) { + $allowed[] = chr($code); + } + + continue; + } + + $allowed[] = $char; + $this->position++; + } + + if ($this->position >= $this->length || $this->pattern[$this->position] !== ']') { + throw new UnsupportedPatternException(); + } + + $this->position++; + + return $negated ? $this->pickNegated($allowed) : $this->pickPreferred($allowed); + } + + private function parseGroup(): string + { + $this->position++; + + // Skip non-capturing / named group prefixes (?:..), (?P..), (?..). + if ( + $this->position < $this->length - 1 + && $this->pattern[$this->position] === '?' + && $this->pattern[$this->position + 1] === ':' + ) { + $this->position += 2; + } elseif ( + $this->position < $this->length + && $this->pattern[$this->position] === '?' + ) { + // Lookarounds, named groups with special syntax, atomic groups, etc. + throw new UnsupportedPatternException(); + } + + $inner = $this->parseAlternation(); + + if ($this->position >= $this->length || $this->pattern[$this->position] !== ')') { + throw new UnsupportedPatternException(); + } + + $this->position++; + + return $inner; + } + + /** + * Reads the quantifier following the current atom and returns ``[min, max]``. + * + * ``max`` is informational only; the generator always emits the minimum + * number of repetitions (with ``*`` and ``?`` normalized to zero). + * + * @return array{0: int, 1: int|null} + */ + private function parseQuantifier(): array + { + if ($this->position >= $this->length) { + return [1, 1]; + } + + $char = $this->pattern[$this->position]; + + $bounds = match ($char) { + '?' => [0, 1], + '*' => [0, null], + '+' => [1, null], + default => null, + }; + + if ($bounds !== null) { + $this->position++; + $this->consumeGreedyModifier(); + + return $bounds; + } + + if ($char === '{') { + return $this->parseBraceQuantifier(); + } + + return [1, 1]; + } + + /** + * @return array{0: int, 1: int|null} + */ + private function parseBraceQuantifier(): array + { + $end = strpos($this->pattern, '}', $this->position); + if ($end === false) { + throw new UnsupportedPatternException(); + } + + $body = substr($this->pattern, $this->position + 1, $end - $this->position - 1); + + $this->position = $end + 1; + $this->consumeGreedyModifier(); + + if (preg_match('/^(\d+)(?:,(\d*))?$/', $body, $matches) !== 1) { + throw new UnsupportedPatternException(); + } + + $min = (int) $matches[1]; + $max = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : null; + + return [$min, $max]; + } + + private function consumeGreedyModifier(): void + { + if ( + $this->position < $this->length + && ($this->pattern[$this->position] === '?' || $this->pattern[$this->position] === '+') + ) { + $this->position++; + } + } + + /** + * Prefer letters, then digits, then anything printable. Keeps samples + * readable (``[A-Z0-9]`` → ``A``, not ``0``). + * + * @param list $chars + */ + private function pickPreferred(array $chars): string + { + foreach (['/[A-Za-z]/', '/\d/', '/[^\s\/]/'] as $preference) { + foreach ($chars as $c) { + if (preg_match($preference, $c) === 1) { + return $c; + } + } + } + + return $chars[0]; + } + + /** + * @param list $forbidden + */ + private function pickNegated(array $forbidden): string + { + $lookup = array_flip($forbidden); + + foreach (['a', 'A', '0', '_', '-'] as $candidate) { + if (! isset($lookup[$candidate])) { + return $candidate; + } + } + + throw new UnsupportedPatternException(); + } + + /** + * @return list + */ + private function expandEscapedClassChar(string $char): array + { + return match ($char) { + 'd' => ['0'], + 'w' => ['a'], + 's' => [' '], + 'D', 'W', 'S' => throw new UnsupportedPatternException(), + default => [$char], + }; + } +} diff --git a/system/Commands/Utilities/Routes/SampleURIGenerator.php b/system/Commands/Utilities/Routes/SampleURIGenerator.php index a0e5f09bc2cb..cc448c925498 100644 --- a/system/Commands/Utilities/Routes/SampleURIGenerator.php +++ b/system/Commands/Utilities/Routes/SampleURIGenerator.php @@ -15,6 +15,7 @@ use CodeIgniter\Router\RouteCollection; use Config\App; +use Config\Routing; /** * Generate a sample URI path from route key regex. @@ -23,10 +24,20 @@ */ final class SampleURIGenerator { + /** + * Placeholder inserted into a sample URI segment when no sample can be + * resolved. The resulting URI matches no route, so the ``spark routes`` + * command reports that route's filters as ````. + */ + public const UNKNOWN_SAMPLE = '::unknown::'; + private readonly RouteCollection $routes; + private readonly Routing $config; + private readonly PlaceholderSampleGenerator $sampleGenerator; /** - * Sample URI path for placeholder. + * Built-in sample URI paths for the standard placeholders shipped with + * ``RouteCollection``. * * @var array */ @@ -39,9 +50,18 @@ final class SampleURIGenerator 'hash' => 'abc_123', ]; - public function __construct(?RouteCollection $routes = null) + /** + * Memoized resolved samples, keyed by placeholder name. + * + * @var array + */ + private array $resolvedCache = []; + + public function __construct(?RouteCollection $routes = null, ?Routing $config = null) { - $this->routes = $routes ?? service('routes'); + $this->routes = $routes ?? service('routes'); + $this->config = $config ?? config(Routing::class); + $this->sampleGenerator = new PlaceholderSampleGenerator(); } /** @@ -62,7 +82,7 @@ public function get(string $routeKey): string } foreach ($this->routes->getPlaceholders() as $placeholder => $regex) { - $sample = $this->samples[$placeholder] ?? '::unknown::'; + $sample = $this->resolveSample($placeholder, $regex); $sampleUri = str_replace('(' . $regex . ')', $sample, $sampleUri); } @@ -70,4 +90,36 @@ public function get(string $routeKey): string // auto route return str_replace('[/...]', '/1/2/3/4/5', $sampleUri); } + + private function resolveSample(string $placeholder, string $regex): string + { + if (isset($this->resolvedCache[$placeholder])) { + return $this->resolvedCache[$placeholder]; + } + + $sample = $this->matchingSample($this->config->placeholderSamples[$placeholder] ?? null, $regex) + ?? $this->matchingSample($this->samples[$placeholder] ?? null, $regex) + ?? $this->sampleGenerator->generate($regex) + ?? self::UNKNOWN_SAMPLE; + + $this->resolvedCache[$placeholder] = $sample; + + return $sample; + } + + /** + * Returns the given sample when it is set and matches the placeholder + * regex, otherwise ``null`` so resolution falls through to the next source. + * Guards both the configured and built-in samples, since a built-in + * placeholder name may be redefined with a different regex via + * ``RouteCollection::addPlaceholder()``. + */ + private function matchingSample(?string $sample, string $regex): ?string + { + if ($sample === null) { + return null; + } + + return @preg_match('#^(?:' . $regex . ')$#', $sample) === 1 ? $sample : null; + } } diff --git a/system/Commands/Utilities/Routes/UnsupportedPatternException.php b/system/Commands/Utilities/Routes/UnsupportedPatternException.php new file mode 100644 index 000000000000..7fed29e52c11 --- /dev/null +++ b/system/Commands/Utilities/Routes/UnsupportedPatternException.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 CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Internal control-flow exception raised when `PlaceholderSampleGenerator` + * encounters a regex fragment it cannot reverse. + * + * The caller turns this into a ``null`` return. + * + * @internal + */ +final class UnsupportedPatternException extends RuntimeException +{ +} diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php index 99afdc9f69eb..f64fe2d8ab50 100644 --- a/system/Commands/Worker/WorkerInstall.php +++ b/system/Commands/Worker/WorkerInstall.php @@ -13,27 +13,23 @@ namespace CodeIgniter\Commands\Worker; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * Install Worker Mode for FrankenPHP. - * - * This command sets up the necessary files to run CodeIgniter 4 - * in FrankenPHP worker mode for improved performance. + * Installs the files needed to run CodeIgniter 4 in FrankenPHP worker mode. */ -class WorkerInstall extends BaseCommand +#[Command( + name: 'worker:install', + description: 'Install FrankenPHP worker mode by creating necessary configuration files', + group: 'Worker Mode', +)] +class WorkerInstall extends AbstractCommand { - 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) + * Template file mappings (template => destination path). * * @var array */ @@ -42,9 +38,18 @@ class WorkerInstall extends BaseCommand 'Caddyfile.tpl' => 'Caddyfile', ]; - public function run(array $params) + protected function configure(): void + { + $this->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Overwrite existing files.', + )); + } + + protected function execute(array $arguments, array $options): int { - $force = array_key_exists('force', $params) || CLI::getOption('force'); + $force = $options['force'] === true; CLI::write('Setting up FrankenPHP Worker Mode', 'yellow'); CLI::newLine(); @@ -53,63 +58,46 @@ public function run(array $params) $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(); + CLI::error(sprintf('Failed to read template: %s', $template), 'light_gray', 'red'); 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(); + CLI::error(sprintf('Failed to create file: %s', clean_path($target)), 'light_gray', 'red'); return EXIT_ERROR; } if ($force && $isFile) { - CLI::write(' File overwritten: ' . clean_path($target), 'yellow'); + CLI::write(sprintf(' File overwritten: %s', clean_path($target)), 'yellow'); } else { - CLI::write(' File created: ' . clean_path($target), 'green'); + CLI::write(sprintf(' File created: %s', 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(); @@ -119,10 +107,7 @@ public function run(array $params) return EXIT_SUCCESS; } - /** - * Display next steps to the user - */ - protected function showNextSteps(): void + private function showNextSteps(): void { CLI::write('Next Steps:', 'yellow'); CLI::newLine(); @@ -133,6 +118,5 @@ protected function showNextSteps(): void 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 index 0d083f2e5b49..3e7d8c1111c2 100644 --- a/system/Commands/Worker/WorkerUninstall.php +++ b/system/Commands/Worker/WorkerUninstall.php @@ -13,26 +13,23 @@ namespace CodeIgniter\Commands\Worker; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * Uninstall Worker Mode for FrankenPHP. - * - * This command removes the files created by the worker:install command. + * Removes the files created by the worker:install command. */ -class WorkerUninstall extends BaseCommand +#[Command( + name: 'worker:uninstall', + description: 'Remove FrankenPHP worker mode configuration files', + group: 'Worker Mode', +)] +class WorkerUninstall extends AbstractCommand { - 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) + * Files to remove (must match the worker:install command). * * @var list */ @@ -41,80 +38,103 @@ class WorkerUninstall extends BaseCommand 'Caddyfile', ]; - public function run(array $params) + protected function configure(): void { - $force = array_key_exists('force', $params) || CLI::getOption('force'); + $this->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Skip the confirmation prompt.', + )); + } - CLI::write('Uninstalling FrankenPHP Worker Mode', 'yellow'); - CLI::newLine(); + protected function interact(array &$arguments, array &$options): void + { + if ($this->hasUnboundOption('force', $options)) { + return; + } - // Find existing files - $existing = []; + if ($this->existingFiles() === []) { + return; + } - foreach ($this->files as $file) { - $path = ROOTPATH . $file; - if (is_file($path)) { - $existing[] = $file; - } + if (CLI::prompt('Remove the FrankenPHP worker mode files?', ['y', 'n']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option } + } + + protected function execute(array $arguments, array $options): int + { + $existing = $this->existingFiles(); - // 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'); + if ($options['force'] === false) { + if ($this->isInteractive()) { + CLI::write('Uninstall cancelled.', 'yellow'); - foreach ($existing as $file) { - CLI::write(' - ' . $file, 'white'); - } - CLI::newLine(); + return EXIT_SUCCESS; + } - // Confirm deletion unless --force is used - if (! $force) { - $confirm = CLI::prompt('Are you sure you want to remove these files?', ['y', 'n']); - CLI::newLine(); + CLI::error('Uninstall aborted: pass --force to remove worker mode files in non-interactive mode.', 'light_gray', 'red'); - if ($confirm !== 'y') { - CLI::write('Uninstall cancelled.', 'yellow'); - CLI::newLine(); + return EXIT_ERROR; + } - return EXIT_ERROR; - } + CLI::newLine(); + CLI::write('The following files will be removed:', 'yellow'); + + foreach ($existing as $file) { + CLI::write(sprintf(' - %s', $file), 'white'); } + CLI::newLine(); + $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'); + CLI::error(sprintf('Failed to remove file: %s', clean_path($path)), 'light_gray', 'red'); continue; } - CLI::write(' File removed: ' . clean_path($path), 'green'); + CLI::write(sprintf(' File removed: %s', clean_path($path)), 'green'); + $removed[] = $file; } - // Summary CLI::newLine(); + if ($removed === []) { - CLI::error('No files were removed.'); - CLI::newLine(); + CLI::error('No files were removed.', 'light_gray', 'red'); return EXIT_ERROR; } CLI::write('Worker mode files removed successfully!', 'green'); - CLI::newLine(); return EXIT_SUCCESS; } + + /** + * @return list + */ + private function existingFiles(): array + { + $existing = []; + + foreach ($this->files as $file) { + if (is_file(ROOTPATH . $file)) { + $existing[] = $file; + } + } + + return $existing; + } } diff --git a/system/Common.php b/system/Common.php index d74fe403c302..7a69bba877f9 100644 --- a/system/Common.php +++ b/system/Common.php @@ -12,8 +12,10 @@ */ use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\CLI\Console; use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\Factories; +use CodeIgniter\Context\Context; use CodeIgniter\Cookie\Cookie; use CodeIgniter\Cookie\CookieStore; use CodeIgniter\Cookie\Exceptions\CookieException; @@ -126,7 +128,7 @@ function command(string $command) $regexString = '([^\s]+?)(?:\s|(? */ - $params = []; - $command = array_shift($args); - $optionValue = false; - - foreach ($args as $i => $arg) { - if (mb_strpos($arg, '-') !== 0) { - if ($optionValue) { - // if this was an option value, it was already - // included in the previous iteration - $optionValue = false; - } else { - // add to segments if not starting with '-' - // and not an option value - $params[] = $arg; - } - - continue; - } - - $arg = ltrim($arg, '-'); - $value = null; - - if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) { - $value = $args[$i + 1]; - $optionValue = true; + // Don't show the header as it is not needed when running commands from code. + if (! in_array('--no-header', $tokens, true)) { + if (! in_array('--', $tokens, true)) { + $tokens[] = '--no-header'; + } else { + $index = (int) array_search('--', $tokens, true); + array_splice($tokens, $index, 0, '--no-header'); } - - $params[$arg] = $value; } + // Prepend an application name, as Console expects one. + array_unshift($tokens, 'spark'); + try { ob_start(); - service('commands')->run($command, $params); + (new Console())->run($tokens); return ob_get_contents(); } finally { @@ -216,6 +200,17 @@ function config(string $name, bool $getShared = true) } } +if (! function_exists('context')) { + /** + * Provides access to the Context object, which is used to store + * contextual data during a request that can be accessed globally. + */ + function context(): Context + { + return service('context'); + } +} + if (! function_exists('cookie')) { /** * Simpler way to create a new Cookie instance. @@ -327,7 +322,7 @@ function csp_style_nonce(): string { $csp = service('csp'); - if (! $csp->enabled()) { + if (! $csp->styleNonceEnabled()) { return ''; } @@ -343,7 +338,7 @@ function csp_script_nonce(): string { $csp = service('csp'); - if (! $csp->enabled()) { + if (! $csp->scriptNonceEnabled()) { return ''; } diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 40782a243b96..87b3f18ce97a 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -311,6 +311,14 @@ protected function registerProperties() } foreach ($properties as $property => $value) { + // Directives are recognized only at the property root. + if ($value instanceof Merge) { + $this->{$property} = $this->applyMerge($this->{$property} ?? null, $value); + + continue; + } + + // Legacy behavior - unchanged, and on the hot path with no extra checks. if (isset($this->{$property}) && is_array($this->{$property}) && is_array($value)) { $this->{$property} = array_merge($this->{$property}, $value); } else { @@ -319,4 +327,122 @@ protected function registerProperties() } } } + + /** + * Applies a property-root Merge directive against the current value. + * + * REPLACE is terminal - its payload is taken verbatim. The list strategies + * (APPEND/PREPEND/BEFORE/AFTER) resolve via mergeList(). BY_KEY recurses via + * mergeByKey(), honoring nested directives. + */ + private function applyMerge(mixed $current, Merge $directive): mixed + { + return match ($directive->strategy) { + Merge::REPLACE => $directive->value, + Merge::BY_KEY => $this->mergeByKey(is_array($current) ? $current : [], $directive->value), + Merge::APPEND, Merge::PREPEND, Merge::BEFORE, Merge::AFTER => $this->mergeList(is_array($current) ? $current : [], $directive), + }; + } + + /** + * Resolves a list directive (APPEND, PREPEND, BEFORE, AFTER) against the + * current value treated as a list. + * + * The directives never introduce a duplicate value: the incoming payload is + * de-duplicated against itself (keeping first-seen order) and values already + * in the list are not added again. Duplicates that already exist in the + * current list are left untouched. Then: + * - APPEND/PREPEND add only the values that are absent - already-present + * values are left where they are (no relocation). + * - BEFORE/AFTER move an already-present value to the anchor position, but + * only when the anchor exists. If the anchor is missing they fall back to + * APPEND/PREPEND respectively and do not relocate already-present values. + * + * The anchor is matched strictly (===) against the list elements, using the + * first match. Do not use a value as both the anchor and an inserted value. + * + * @param array $current + * + * @return list + */ + private function mergeList(array $current, Merge $directive): array + { + $current = array_values($current); + + // De-duplicate the payload itself (strict, first-seen order) so a value + // repeated within it is not inserted twice. + $incoming = []; + + foreach ($directive->value as $value) { + if (! in_array($value, $incoming, true)) { + $incoming[] = $value; + } + } + + $anchored = $directive->strategy === Merge::BEFORE || $directive->strategy === Merge::AFTER; + $anchorFound = $anchored && in_array($directive->anchor, $current, true); + + if ($anchorFound) { + // Move-to-position: pull out any present copies, then insert the + // whole incoming block at the (recomputed) anchor position. + $current = array_values(array_filter( + $current, + static fn ($value): bool => ! in_array($value, $incoming, true), + )); + + $index = (int) array_search($directive->anchor, $current, true); + $offset = $directive->strategy === Merge::AFTER ? $index + 1 : $index; + + array_splice($current, $offset, 0, $incoming); + + return $current; + } + + // APPEND/PREPEND, or BEFORE/AFTER with a missing anchor: add only the + // values not already present, without relocating anything. + $incoming = array_values(array_filter( + $incoming, + static fn ($value): bool => ! in_array($value, $current, true), + )); + + return $directive->strategy === Merge::PREPEND || $directive->strategy === Merge::BEFORE + ? array_merge($incoming, $current) + : array_merge($current, $incoming); + } + + /** + * Recursive by-key merge used by Merge::byKey(): string keys recurse, integer + * keys append, scalar leaves are replaced, and nested Merge directives are + * honored. A missing/non-array current child uses [] as its base, so directives + * in brand-new subtrees are still resolved. + * + * @param array $current + * @param array $incoming + * + * @return array + */ + private function mergeByKey(array $current, array $incoming): array + { + foreach ($incoming as $key => $value) { + if ($value instanceof Merge) { + if (is_int($key)) { + // No stable current element at an appended position; resolve against null. + $current[] = $this->applyMerge(null, $value); + } else { + $current[$key] = $this->applyMerge($current[$key] ?? null, $value); + } + } elseif (is_int($key)) { + $current[] = $value; + } elseif (is_array($value)) { + $current[$key] = $this->mergeByKey( + isset($current[$key]) && is_array($current[$key]) ? $current[$key] : [], + $value, + ); + } else { + $current[$key] = $value; + } + } + + return $current; + } } diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 64b5266da041..9ea86ebd0b8f 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -29,6 +29,7 @@ use CodeIgniter\Debug\Toolbar; use CodeIgniter\Email\Email; use CodeIgniter\Encryption\EncrypterInterface; +use CodeIgniter\EnvironmentDetector; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Filters\Filters; use CodeIgniter\Format\Format; @@ -45,7 +46,9 @@ use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Input\InputDataFactory; use CodeIgniter\Language\Language; +use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; use CodeIgniter\Pager\Pager; use CodeIgniter\Router\RouteCollection; @@ -111,14 +114,17 @@ * @method static CURLRequest curlrequest($options = [], ResponseInterface $response = null, App $config = null, $getShared = true) * @method static Email email($config = null, $getShared = true) * @method static EncrypterInterface encrypter(Encryption $config = null, $getShared = false) + * @method static EnvironmentDetector environment(?string $environment = null, bool $getShared = true) * @method static Exceptions exceptions(ConfigExceptions $config = null, $getShared = true) * @method static Filters filters(ConfigFilters $config = null, $getShared = true) * @method static Format format(ConfigFormat $config = null, $getShared = true) * @method static Honeypot honeypot(ConfigHoneyPot $config = null, $getShared = true) * @method static BaseHandler image($handler = null, Images $config = null, $getShared = true) * @method static IncomingRequest incomingrequest(?App $config = null, bool $getShared = true) + * @method static InputDataFactory inputdatafactory(bool $getShared = true) * @method static Iterator iterator($getShared = true) * @method static Language language($locale = null, $getShared = true) + * @method static LockManager locks(?CacheInterface $cache = null, bool $getShared = true) * @method static Logger logger($getShared = true) * @method static MigrationRunner migrations(Migrations $config = null, ConnectionInterface $db = null, $getShared = true) * @method static Negotiate negotiator(RequestInterface $request = null, $getShared = true) @@ -175,15 +181,6 @@ class BaseService */ protected static $discovered = false; - /** - * A cache of other service classes we've found. - * - * @var array - * - * @deprecated 4.5.0 No longer used. - */ - protected static $services = []; - /** * A cache of the names of services classes found. * diff --git a/system/Config/Filters.php b/system/Config/Filters.php index 80662ede4bb3..1ebc05033024 100644 --- a/system/Config/Filters.php +++ b/system/Config/Filters.php @@ -21,6 +21,7 @@ use CodeIgniter\Filters\InvalidChars; use CodeIgniter\Filters\PageCache; use CodeIgniter\Filters\PerformanceMetrics; +use CodeIgniter\Filters\RequestId; use CodeIgniter\Filters\SecureHeaders; /** @@ -47,6 +48,7 @@ class Filters extends BaseConfig 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, + 'requestid' => RequestId::class, ]; /** @@ -64,11 +66,13 @@ class Filters extends BaseConfig */ public array $required = [ 'before' => [ + // 'requestid', // Request ID for each request 'forcehttps', // Force Global Secure Requests 'pagecache', // Web Page Caching ], 'after' => [ 'pagecache', // Web Page Caching + // 'requestid', // Request ID for each request 'performance', // Performance Metrics 'toolbar', // Debug Toolbar ], @@ -106,7 +110,10 @@ class Filters extends BaseConfig * permits any HTTP method to access a controller. Accessing the controller * with a method you don't expect could bypass the filter. * - * @var array> + * **IMPORTANT:** HTTP methods are checked case-sensitively, so you should always + * use the uppercase form to avoid issues. + * + * @var array> */ public array $methods = []; diff --git a/system/Config/Merge.php b/system/Config/Merge.php new file mode 100644 index 000000000000..02672bd33b6b --- /dev/null +++ b/system/Config/Merge.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Describes how a Registrar value should be merged into an existing + * Config property. Interpreted when returned as the value of a config + * property; nested directives are honored inside Merge::byKey(). + * + * @see \CodeIgniter\Config\BaseConfig + */ +final readonly class Merge +{ + /** + * Discard the existing value and use the new one. + */ + public const REPLACE = 'replace'; + + /** + * Add absent list items to the end of the existing value. + */ + public const APPEND = 'append'; + + /** + * Add absent list items to the front of the existing value. + */ + public const PREPEND = 'prepend'; + + /** + * Insert list items immediately before the anchor element. + */ + public const BEFORE = 'before'; + + /** + * Insert list items immediately after the anchor element. + */ + public const AFTER = 'after'; + + /** + * Deep-merge by key: string keys recurse, integer keys append, scalars replace. + */ + public const BY_KEY = 'byKey'; + + /** + * @param self::AFTER|self::APPEND|self::BEFORE|self::BY_KEY|self::PREPEND|self::REPLACE $strategy + * @param mixed $value Any value for REPLACE; array for the list strategies and BY_KEY. + * @param mixed $anchor The element BEFORE/AFTER position against (matched strictly). + */ + private function __construct( + public string $strategy, + public mixed $value, + public mixed $anchor = null, + ) { + } + + /** + * Replace the existing value entirely (terminal: the payload is used + * verbatim). Accepts any type, so it works for scalars too: + * Merge::replace(false), Merge::replace('driver'), Merge::replace(null), + * or arrays (e.g. ['a','b'] + ['c'] => ['c']). + */ + public static function replace(mixed $value): self + { + return new self(self::REPLACE, $value); + } + + /** + * Append absent list items to the end of the existing value + * (e.g. ['a','b'] + ['b','c'] => ['a','b','c']). Values already present are + * left where they are. The payload is literal - for nested control, use + * byKey() rather than nesting directives in an append() payload. List keys + * are not preserved: the value is treated as a list. + * + * @param list $value + */ + public static function append(array $value): self + { + return new self(self::APPEND, $value); + } + + /** + * Prepend absent list items to the front of the existing value + * (e.g. ['a','b'] + ['c'] => ['c','a','b']). Values already present are left + * where they are. List keys are not preserved: the value is treated as a list. + * + * @param list $value + */ + public static function prepend(array $value): self + { + return new self(self::PREPEND, $value); + } + + /** + * Insert list items immediately before the first element equal (===) to + * $anchor. An already-present value is moved to this position. If the anchor + * is not in the list this falls back to prepend() and does not relocate + * already-present values. List keys are not preserved. + * + * @param list $value + * + * @throws InvalidArgumentException if $anchor is also one of the inserted values. + */ + public static function before(mixed $anchor, array $value): self + { + self::assertAnchorNotInPayload($anchor, $value, self::BEFORE); + + return new self(self::BEFORE, $value, $anchor); + } + + /** + * Insert list items immediately after the first element equal (===) to + * $anchor. An already-present value is moved to this position. If the anchor + * is not in the list this falls back to append() and does not relocate + * already-present values. List keys are not preserved. + * + * @param list $value + * + * @throws InvalidArgumentException if $anchor is also one of the inserted values. + */ + public static function after(mixed $anchor, array $value): self + { + self::assertAnchorNotInPayload($anchor, $value, self::AFTER); + + return new self(self::AFTER, $value, $anchor); + } + + /** + * Guards against anchoring a before()/after() insert on a value that is also + * being inserted. That request is contradictory - the anchor would be removed + * by de-duplication before it could be located - so it is rejected outright. + * + * @param list $value + */ + private static function assertAnchorNotInPayload(mixed $anchor, array $value, string $strategy): void + { + if (in_array($anchor, $value, true)) { + throw new InvalidArgumentException( + 'Merge::' . $strategy . '() cannot use a value that is also being inserted as its anchor.', + ); + } + } + + /** + * Deep-merge into the existing value by key: associative (string) keys are + * merged/recursed, list (integer) keys append, scalar leaves are replaced. + * Nested Merge directives ARE honored within the payload. Named byKey() to + * distance it from PHP's array_merge_recursive(), which collects scalars + * into arrays. + * + * @param array $value + */ + public static function byKey(array $value): self + { + return new self(self::BY_KEY, $value); + } +} diff --git a/system/Config/Publisher.php b/system/Config/Publisher.php index 7e1b0ffb54f2..3038644e4aeb 100644 --- a/system/Config/Publisher.php +++ b/system/Config/Publisher.php @@ -32,7 +32,7 @@ class Publisher extends BaseConfig */ public $restrictions = [ ROOTPATH => '*', - FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|avif|bmp|ico|svg)$#i', ]; /** diff --git a/system/Config/Routing.php b/system/Config/Routing.php index 4db55b3aa0c2..3eb28f254b25 100644 --- a/system/Config/Routing.php +++ b/system/Config/Routing.php @@ -146,4 +146,14 @@ class Routing extends BaseConfig * Default: false */ public bool $translateUriToCamelCase = false; + + /** + * Sample values for the ``spark routes`` command, keyed by placeholder + * name without the ``(:...)`` wrapper. Each value must match the + * placeholder's regular expression and overrides the built-in or + * auto-generated sample for that placeholder. + * + * @var array + */ + public array $placeholderSamples = []; } diff --git a/system/Config/Services.php b/system/Config/Services.php index cef65a8895a9..f8664b2f7c63 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -18,6 +18,7 @@ use CodeIgniter\Cache\ResponseCache; use CodeIgniter\CLI\Commands; use CodeIgniter\CodeIgniter; +use CodeIgniter\Context\Context; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\MigrationRunner; use CodeIgniter\Debug\Exceptions; @@ -27,6 +28,7 @@ use CodeIgniter\Email\Email; use CodeIgniter\Encryption\EncrypterInterface; use CodeIgniter\Encryption\Encryption; +use CodeIgniter\EnvironmentDetector; use CodeIgniter\Filters\Filters; use CodeIgniter\Format\Format; use CodeIgniter\Honeypot\Honeypot; @@ -44,7 +46,9 @@ use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Input\InputDataFactory; use CodeIgniter\Language\Language; +use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; use CodeIgniter\Pager\Pager; use CodeIgniter\Router\RouteCollection; @@ -129,6 +133,20 @@ public static function cache(?Cache $config = null, bool $getShared = true) return CacheFactory::getHandler($config); } + /** + * The locks service provides atomic locks backed by supported cache handlers. + */ + public static function locks(?CacheInterface $cache = null, bool $getShared = true): LockManager + { + if ($getShared) { + return static::getSharedInstance('locks', $cache); + } + + $cache ??= AppServices::get('cache'); + + return new LockManager($cache); + } + /** * The CLI Request class provides for ways to interact with * a command line request. @@ -198,6 +216,8 @@ public static function csp(?CSPConfig $config = null, bool $getShared = true) * The CURL Request class acts as a simple HTTP client for interacting * with other servers, typically through APIs. * + * @todo v4.8.0 Remove $config parameter since unused + * * @return CURLRequest */ public static function curlrequest(array $options = [], ?ResponseInterface $response = null, ?App $config = null, bool $getShared = true) @@ -207,7 +227,7 @@ public static function curlrequest(array $options = [], ?ResponseInterface $resp } $config ??= config(App::class); - $response ??= new Response($config); + $response ??= new Response(); return new CURLRequest( $config, @@ -256,6 +276,23 @@ public static function encrypter(?EncryptionConfig $config = null, $getShared = return $encryption->initialize($config); } + /** + * Provides a simple way to determine the current environment + * of the application. + * + * Primarily intended for testing environment-specific branches by + * mocking this service. Mocking it does not modify the `ENVIRONMENT` + * constant. It only affects code paths that resolve and use this service. + */ + public static function environment(?string $environment = null, bool $getShared = true): EnvironmentDetector + { + if ($getShared) { + return static::getSharedInstance('environment', $environment); + } + + return new EnvironmentDetector($environment); + } + /** * The Exceptions class holds the methods that handle: * @@ -351,6 +388,18 @@ public static function image(?string $handler = null, ?Images $config = null, bo return new $class($config); } + /** + * Returns the typed input data factory. + */ + public static function inputdatafactory(bool $getShared = true): InputDataFactory + { + if ($getShared) { + return static::getSharedInstance('inputdatafactory'); + } + + return new InputDataFactory(); + } + /** * The Iterator class provides a simple way of looping over a function * and timing the results and memory usage. Used when debugging and @@ -515,17 +564,10 @@ public static function renderer(?string $viewPath = null, ?ViewConfig $config = * createRequest() injects IncomingRequest or CLIRequest. * * @return CLIRequest|IncomingRequest - * - * @deprecated The parameter $config and $getShared are deprecated. */ - public static function request(?App $config = null, bool $getShared = true) + public static function request() { - if ($getShared) { - return static::getSharedInstance('request', $config); - } - - // @TODO remove the following code for backward compatibility - return AppServices::incomingrequest($config, $getShared); + return static::$instances['request'] ?? static::incomingrequest(getShared: false); } /** @@ -576,6 +618,8 @@ public static function incomingrequest(?App $config = null, bool $getShared = tr /** * The Response class models an HTTP response. * + * @todo v4.8.0 Remove $config parameter since unused + * * @return ResponseInterface */ public static function response(?App $config = null, bool $getShared = true) @@ -584,14 +628,14 @@ public static function response(?App $config = null, bool $getShared = true) return static::getSharedInstance('response', $config); } - $config ??= config(App::class); - - return new Response($config); + return new Response(); } /** * The Redirect class provides nice way of working with redirects. * + * @todo v4.8.0 Remove $config parameter since unused + * * @return RedirectResponse */ public static function redirectresponse(?App $config = null, bool $getShared = true) @@ -600,8 +644,7 @@ public static function redirectresponse(?App $config = null, bool $getShared = t return static::getSharedInstance('redirectresponse', $config); } - $config ??= config(App::class); - $response = new RedirectResponse($config); + $response = new RedirectResponse(); $response->setProtocolVersion(AppServices::get('request')->getProtocolVersion()); return $response; @@ -871,4 +914,16 @@ public static function typography(bool $getShared = true) return new Typography(); } + + /** + * The Context class provides a way to store and retrieve static data throughout requests. + */ + public static function context(bool $getShared = true): Context + { + if ($getShared) { + return static::getSharedInstance('context'); + } + + return new Context(); + } } diff --git a/system/Context/Context.php b/system/Context/Context.php new file mode 100644 index 000000000000..e1e95aeac260 --- /dev/null +++ b/system/Context/Context.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\Context; + +use CodeIgniter\Helpers\Array\ArrayHelper; +use SensitiveParameter; +use SensitiveParameterValue; + +final class Context +{ + /** + * The data stored in the context. + * + * @var array + */ + private array $data = []; + + /** + * The data that is stored but not included in logs. + * + * @var array + */ + private array $hiddenData = []; + + /** + * Set a key-value pair to the context. + * Supports dot notation for nested arrays. + * + * @param array|string $key The key to identify the data. Can be a string or an array of key-value pairs. + * @param mixed $value The value to be stored in the context. + * + * @return $this + */ + public function set(array|string $key, mixed $value = null): self + { + if (is_array($key)) { + foreach ($key as $k => $v) { + if (! $this->hasDotNotation($k)) { + $this->data[$k] = $v; + + continue; + } + + ArrayHelper::dotSet($this->data, $k, $v); + } + + return $this; + } + + if (! $this->hasDotNotation($key)) { + $this->data[$key] = $value; + + return $this; + } + + ArrayHelper::dotSet($this->data, $key, $value); + + return $this; + } + + /** + * Set a hidden key-value pair to the context. This data will not be included in logs. + * Supports dot notation for nested arrays. + * + * @param array|string $key The key to identify the data. Can be a string or an array of key-value pairs. + * @param mixed $value The value to be stored in the context. + * + * @return $this + */ + public function setHidden(#[SensitiveParameter] array|string $key, #[SensitiveParameter] mixed $value = null): self + { + if (is_array($key)) { + foreach ($key as $k => $v) { + if (! $this->hasDotNotation($k)) { + $this->hiddenData[$k] = $v; + + continue; + } + + ArrayHelper::dotSet($this->hiddenData, $k, $v); + } + + return $this; + } + + if (! $this->hasDotNotation($key)) { + $this->hiddenData[$key] = $value; + + return $this; + } + + ArrayHelper::dotSet($this->hiddenData, $key, $value); + + return $this; + } + + /** + * Get a value from the context by its key, or return a default value if the key does not exist. + * Supports dot notation for nested arrays. + * + * @param string $key The key to identify the data. + * @param mixed $default The default value to return if the key does not exist in the context. + * + * @return mixed The value associated with the key, or the default value if the key does not exist. + */ + public function get(string $key, mixed $default = null): mixed + { + if (! $this->has($key)) { + return $default; + } + + // Exit early if the key is not a dot notation to avoid unnecessary processing in the common case. + if (! $this->hasDotNotation($key)) { + return $this->data[$key]; + } + + return ArrayHelper::dotSearch($key, $this->data); + } + + /** + * Get only the specified keys from the context. If a key does not exist, it will be ignored. + * Supports dot notation for nested arrays. + * + * @param list|string $keys An array of keys to retrieve from the context. + * + * @return array An array of key-value pairs for the specified keys that exist in the context. + */ + public function getOnly(array|string $keys): array + { + return ArrayHelper::dotOnly($this->data, $keys); + } + + /** + * Get all keys from the context except the specified keys. + * Supports dot notation for nested arrays. + * + * @param list|string $keys An array of keys to exclude from the context. + * + * @return array An array of key-value pairs for all keys in the context except the specified keys. + */ + public function getExcept(array|string $keys): array + { + return ArrayHelper::dotExcept($this->data, $keys); + } + + /** + * Get all data from the context + * + * @return array An array of all key-value pairs in the context. + */ + public function getAll(): array + { + return $this->data; + } + + /** + * Get a hidden value from the context by its key, or return a default value if the key does not exist. + * Supports dot notation for nested arrays. + * + * @param string $key The key to identify the data. + * @param mixed $default The default value to return if the key does not exist in the context. + * + * @return mixed The value associated with the key, or the default value if the key does not exist. + */ + public function getHidden(#[SensitiveParameter] string $key, #[SensitiveParameter] mixed $default = null): mixed + { + if (! $this->hasHidden($key)) { + return $default; + } + + // Exit early if the key is not a dot notation to avoid unnecessary processing in the common case. + if (! $this->hasDotNotation($key)) { + return $this->hiddenData[$key]; + } + + return ArrayHelper::dotSearch($key, $this->hiddenData); + } + + /** + * Get only the specified keys from the hidden context. If a key does not exist, it will be ignored. + * Supports dot notation for nested arrays. + * + * @param list|string $keys An array of keys to retrieve from the hidden context. + * + * @return array An array of key-value pairs for the specified keys that exist in the hidden context. + */ + public function getOnlyHidden(#[SensitiveParameter] array|string $keys): array + { + return ArrayHelper::dotOnly($this->hiddenData, $keys); + } + + /** + * Get all keys from the hidden context except the specified keys. + * Supports dot notation for nested arrays. + * + * @param list|string $keys An array of keys to exclude from the hidden context. + * + * @return array An array of key-value pairs for all keys in the hidden context except the specified keys. + */ + public function getExceptHidden(#[SensitiveParameter] array|string $keys): array + { + return ArrayHelper::dotExcept($this->hiddenData, $keys); + } + + /** + * Get all hidden data from the context + * + * @return array An array of all key-value pairs in the hidden context. + */ + public function getAllHidden(): array + { + return $this->hiddenData; + } + + /** + * Check if a key exists in the context. + * Supports dot notation for nested arrays. + * + * @param string $key The key to check for existence in the context. + * + * @return bool True if the key exists in the context, false otherwise. + */ + public function has(string $key): bool + { + if (! $this->hasDotNotation($key)) { + return array_key_exists($key, $this->data); + } + + return ArrayHelper::dotHas($key, $this->data); + } + + /** + * Check if a key exists in the hidden context. + * Supports dot notation for nested arrays. + * + * @param string $key The key to check for existence in the hidden context. + * + * @return bool True if the key exists in the hidden context, false otherwise. + */ + public function hasHidden(string $key): bool + { + if (! $this->hasDotNotation($key)) { + return array_key_exists($key, $this->hiddenData); + } + + return ArrayHelper::dotHas($key, $this->hiddenData); + } + + /** + * Remove a key-value pair from the context by its key. + * Supports dot notation for nested arrays. + * + * @param list|string $key The key to identify the data to be removed from the context. + * + * @return $this + */ + public function remove(array|string $key): self + { + if (is_array($key)) { + foreach ($key as $k) { + if (! $this->hasDotNotation($k)) { + unset($this->data[$k]); + + continue; + } + + ArrayHelper::dotUnset($this->data, $k); + } + + return $this; + } + + if (! $this->hasDotNotation($key)) { + unset($this->data[$key]); + + return $this; + } + + ArrayHelper::dotUnset($this->data, $key); + + return $this; + } + + /** + * Remove a key-value pair from the hidden context by its key. + * Supports dot notation for nested arrays. + * + * @param list|string $key The key to identify the data to be removed from the hidden context. + * + * @return $this + */ + public function removeHidden(#[SensitiveParameter] array|string $key): self + { + if (is_array($key)) { + foreach ($key as $k) { + if (! $this->hasDotNotation($k)) { + unset($this->hiddenData[$k]); + + continue; + } + + ArrayHelper::dotUnset($this->hiddenData, $k); + } + + return $this; + } + + if (! $this->hasDotNotation($key)) { + unset($this->hiddenData[$key]); + + return $this; + } + + ArrayHelper::dotUnset($this->hiddenData, $key); + + return $this; + } + + /** + * Clear all data from the context, including hidden data. + * + * @return $this + */ + public function clearAll(): self + { + $this->clear(); + $this->clearHidden(); + + return $this; + } + + /** + * Clear all data from the context. + * + * @return $this + */ + public function clear(): self + { + $this->data = []; + + return $this; + } + + /** + * Clear all hidden data from the context. + * + * @return $this + */ + public function clearHidden(): self + { + $this->hiddenData = []; + + return $this; + } + + public function __debugInfo(): array + { + return [ + 'data' => $this->data, + 'hiddenData' => new SensitiveParameterValue($this->hiddenData), + ]; + } + + public function __clone() + { + $this->hiddenData = []; + } + + public function __serialize(): array + { + return [ + 'data' => $this->data, + ]; + } + + /** + * @param array $data + */ + public function __unserialize(array $data): void + { + $this->data = $data['data'] ?? []; + $this->hiddenData = []; + } + + private function hasDotNotation(string $key): bool + { + return str_contains($key, '.'); + } +} diff --git a/system/DataCaster/Cast/FloatCast.php b/system/DataCaster/Cast/FloatCast.php index d2173826265a..858ff7ce7aac 100644 --- a/system/DataCaster/Cast/FloatCast.php +++ b/system/DataCaster/Cast/FloatCast.php @@ -13,6 +13,8 @@ namespace CodeIgniter\DataCaster\Cast; +use CodeIgniter\DataCaster\Exceptions\CastException; + /** * Class FloatCast * @@ -30,6 +32,20 @@ public static function get( self::invalidTypeValueError($value); } - return (float) $value; + $precision = isset($params[0]) ? (int) $params[0] : null; + + if ($precision === null) { + return (float) $value; + } + + $mode = match (strtolower($params[1] ?? 'up')) { + 'up' => PHP_ROUND_HALF_UP, + 'down' => PHP_ROUND_HALF_DOWN, + 'even' => PHP_ROUND_HALF_EVEN, + 'odd' => PHP_ROUND_HALF_ODD, + default => throw CastException::forInvalidFloatRoundingMode($params[1]), + }; + + return round((float) $value, $precision, $mode); } } diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 4900d7cd3db7..787d31d5f0ee 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -13,12 +13,14 @@ namespace CodeIgniter\Database; +use BackedEnum; use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Traits\ConditionalTrait; use Config\Feature; +use TypeError; /** * Class BaseBuilder @@ -31,6 +33,9 @@ class BaseBuilder { use ConditionalTrait; + protected const SELECT_LOCK_FOR_UPDATE = 'forUpdate'; + protected const SELECT_LOCK_SHARED = 'shared'; + /** * Reset DELETE data flag * @@ -109,6 +114,16 @@ class BaseBuilder */ protected $QBOffset = false; + /** + * QB SELECT lock mode + */ + protected ?string $QBSelectLock = null; + + /** + * QB SELECT aggregate helper flag + */ + protected bool $QBSelectUsesAggregate = false; + /** * QB ORDER BY data * @@ -541,8 +556,9 @@ protected function maxMinAvgSum(string $select = '', string $alias = '', string $sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias)); - $this->QBSelect[] = $sql; - $this->QBNoEscape[] = null; + $this->QBSelect[] = $sql; + $this->QBNoEscape[] = null; + $this->QBSelectUsesAggregate = true; return $this; } @@ -630,15 +646,7 @@ public function fromSubquery(BaseBuilder $from, string $alias): self */ public function join(string $table, $cond, string $type = '', ?bool $escape = null) { - if ($type !== '') { - $type = strtoupper(trim($type)); - - if (! in_array($type, $this->joinTypes, true)) { - $type = ''; - } else { - $type .= ' '; - } - } + $type = $this->compileJoinType($type); // Extract any aliases that might exist. We use this information // in the protectIdentifiers to know whether to add a table prefix @@ -648,62 +656,96 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu $escape = $this->db->protectIdentifiers; } - // Do we want to escape the table name? - if ($escape === true) { - $table = $this->db->protectIdentifiers($table, true, null, false); + $table = $this->compileJoinTable($table, $escape); + $cond = $this->compileJoinCondition($cond, $escape); + + // Assemble the JOIN statement + $this->QBJoin[] = $type . 'JOIN ' . $table . $cond; + + return $this; + } + + /** + * Compiles the JOIN table name. + */ + protected function compileJoinTable(string $table, bool $escape): string + { + if ($escape) { + return $this->db->protectIdentifiers($table, true, null, false); } - if ($cond instanceof RawSql) { - $this->QBJoin[] = $type . 'JOIN ' . $table . ' ON ' . $cond; + return $table; + } - return $this; + private function compileJoinType(string $type): string + { + if ($type === '') { + return ''; } - if (! $this->hasOperator($cond)) { - $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')'; - } elseif ($escape === false) { - $cond = ' ON ' . $cond; - } else { - // Split multiple conditions - // @TODO This does not parse `BETWEEN a AND b` correctly. - if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE) >= 1) { - $conditions = []; - $joints = $joints[0]; - array_unshift($joints, ['', 0]); - - for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) { - $joints[$i][1] += strlen($joints[$i][0]); // offset - $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]); - $pos = $joints[$i][1] - strlen($joints[$i][0]); - $joints[$i] = $joints[$i][0]; - } - ksort($conditions); - } else { - $conditions = [$cond]; - $joints = ['']; - } + $type = strtoupper(trim($type)); + + return in_array($type, $this->joinTypes, true) ? $type . ' ' : ''; + } - $cond = ' ON '; + /** + * Compiles the JOIN ON or USING condition. + */ + private function compileJoinCondition(RawSql|string $condition, bool $escape): string + { + if ($condition instanceof RawSql) { + return ' ON ' . $condition; + } - foreach ($conditions as $i => $condition) { - $operator = $this->getOperator($condition); + if (! $this->hasOperator($condition)) { + return ' USING (' . ($escape ? $this->db->escapeIdentifiers($condition) : $condition) . ')'; + } - // Workaround for BETWEEN - if ($operator === false) { - $cond .= $joints[$i] . $condition; + if ($escape === false) { + return ' ON ' . $condition; + } - continue; - } + return $this->compileProtectedJoinCondition($condition); + } - $cond .= $joints[$i]; - $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition; + private function compileProtectedJoinCondition(string $condition): string + { + // Split multiple conditions + // @TODO This does not parse `BETWEEN a AND b` correctly. + if (preg_match_all('/\sAND\s|\sOR\s/i', $condition, $joints, PREG_OFFSET_CAPTURE) >= 1) { + $conditions = []; + $joints = $joints[0]; + array_unshift($joints, ['', 0]); + + for ($i = count($joints) - 1, $pos = strlen($condition); $i >= 0; $i--) { + $joints[$i][1] += strlen($joints[$i][0]); // offset + $conditions[$i] = substr($condition, $joints[$i][1], $pos - $joints[$i][1]); + $pos = $joints[$i][1] - strlen($joints[$i][0]); + $joints[$i] = $joints[$i][0]; } + ksort($conditions); + } else { + $conditions = [$condition]; + $joints = ['']; } - // Assemble the JOIN statement - $this->QBJoin[] = $type . 'JOIN ' . $table . $cond; + $condition = ' ON '; - return $this; + foreach ($conditions as $i => $conditionPart) { + $operator = $this->getOperator($conditionPart); + + // Workaround for BETWEEN + if ($operator === false) { + $condition .= $joints[$i] . $conditionPart; + + continue; + } + + $condition .= $joints[$i]; + $condition .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $conditionPart, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $conditionPart; + } + + return $condition; } /** @@ -736,6 +778,338 @@ public function orWhere($key, $value = null, ?bool $escape = null) return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape); } + /** + * Generates a WHERE clause that compares two columns. + * + * @param non-empty-string $first First column name, optionally with comparison operator + * @param non-empty-string $second Second column name + * @param bool|null $escape Whether to protect identifiers + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereColumn(string $first, string $second, ?bool $escape = null): static + { + return $this->whereColumnHaving('QBWhere', $first, $second, 'AND ', $escape); + } + + /** + * Generates an OR WHERE clause that compares two columns. + * + * @param non-empty-string $first First column name, optionally with comparison operator + * @param non-empty-string $second Second column name + * @param bool|null $escape Whether to protect identifiers + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereColumn(string $first, string $second, ?bool $escape = null): static + { + return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape); + } + + /** + * Generates a WHERE EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function whereExists($subquery): static + { + return $this->whereExistsSubquery($subquery); + } + + /** + * Generates an OR WHERE EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function orWhereExists($subquery): static + { + return $this->whereExistsSubquery($subquery, false, 'OR '); + } + + /** + * Generates a WHERE NOT EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function whereNotExists($subquery): static + { + return $this->whereExistsSubquery($subquery, true); + } + + /** + * Generates an OR WHERE NOT EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function orWhereNotExists($subquery): static + { + return $this->whereExistsSubquery($subquery, true, 'OR '); + } + + /** + * @used-by whereColumn() + * @used-by orWhereColumn() + * + * @param 'QBHaving'|'QBWhere' $qbKey + * @param non-empty-string $first First column name, optionally with comparison operator + * @param non-empty-string $second Second column name + * @param non-empty-string $type + * @param bool|null $escape Whether to protect identifiers + * + * @return $this + * + * @throws InvalidArgumentException + */ + protected function whereColumnHaving(string $qbKey, string $first, string $second, string $type = 'AND ', ?bool $escape = null): static + { + [$first, $operator] = $this->parseWhereColumnFirst($first); + $second = trim($second); + + if ($first === '' || $second === '') { + $caller = debug_backtrace(0, 2)[1]['function']; + + throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', $caller)); + } + + $escape ??= $this->db->protectIdentifiers; + + $this->addWhereHavingCondition($qbKey, [ + 'columnComparison' => true, + 'condition' => '', + 'escape' => $escape, + 'first' => $first, + 'operator' => $operator, + 'second' => $second, + ], $type); + + return $this; + } + + /** + * Extracts the operator from the first whereColumn() column. + * + * @param string $first The first column, optionally ending with a comparison operator + * + * @return array{string, string} + */ + private function parseWhereColumnFirst(string $first): array + { + $first = trim($first); + + if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) { + return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])]; + } + + return [$first, '=']; + } + + /** + * Generates a WHERE field BETWEEN minimum AND maximum SQL query, + * joined with 'AND' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'AND ', $escape); + } + + /** + * Generates a WHERE field BETWEEN minimum AND maximum SQL query, + * joined with 'OR' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'OR ', $escape); + } + + /** + * Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query, + * joined with 'AND' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'AND ', $escape); + } + + /** + * Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query, + * joined with 'OR' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'OR ', $escape); + } + + /** + * Generates a HAVING field BETWEEN minimum AND maximum SQL query, + * joined with 'AND' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function havingBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'AND ', $escape); + } + + /** + * Generates a HAVING field BETWEEN minimum AND maximum SQL query, + * joined with 'OR' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orHavingBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'OR ', $escape); + } + + /** + * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query, + * joined with 'AND' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function havingNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'AND ', $escape); + } + + /** + * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query, + * joined with 'OR' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orHavingNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'OR ', $escape); + } + + /** + * @used-by whereBetween() + * @used-by orWhereBetween() + * @used-by whereNotBetween() + * @used-by orWhereNotBetween() + * @used-by havingBetween() + * @used-by orHavingBetween() + * @used-by havingNotBetween() + * @used-by orHavingNotBetween() + * + * @param 'QBHaving'|'QBWhere' $qbKey + * @param non-empty-string|null $key + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + private function whereBetweenHaving(string $qbKey, ?string $key = null, ?array $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null): static + { + if ($key === null || $key === '') { + throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function'])); + } + + if (! is_array($values) || count($values) !== 2) { + throw new InvalidArgumentException(sprintf('%s() expects $values to be an array containing exactly two values', debug_backtrace(0, 2)[1]['function'])); + } + + $escape ??= $this->db->protectIdentifiers; + $values = array_values($values); + + $lowerBind = $this->setBind($key, $values[0], $escape); + $upperBind = $this->setBind($key, $values[1], $escape); + $not = $not ? ' NOT' : ''; + $this->addWhereHavingCondition($qbKey, [ + 'betweenComparison' => true, + 'condition' => '', + 'escape' => $escape, + 'key' => $key, + 'lowerBind' => $lowerBind, + 'not' => $not, + 'upperBind' => $upperBind, + ], $type); + + return $this; + } + + /** + * @used-by whereExists() + * @used-by orWhereExists() + * @used-by whereNotExists() + * @used-by orWhereNotExists() + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + * + * @throws InvalidArgumentException + */ + protected function whereExistsSubquery($subquery, bool $not = false, string $type = 'AND '): static + { + if (! $this->isSubquery($subquery)) { + throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function'])); + } + + $operator = $not ? 'NOT EXISTS' : 'EXISTS'; + + $this->addWhereHavingCondition('QBWhere', [ + 'condition' => "{$operator} {$this->buildSubquery($subquery, true)}", + 'escape' => false, + ], $type); + + return $this; + } + /** * @used-by where() * @used-by orWhere() @@ -773,8 +1147,6 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type $escape = $this->db->protectIdentifiers; } - $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type); - foreach ($keyValue as $k => $v) { if ($rawSqlOnly) { $k = ''; @@ -823,24 +1195,46 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type $op = ''; } + $condition = $k . $op . $v; + if ($v instanceof RawSql) { $this->{$qbKey}[] = [ - 'condition' => $v->with($prefix . $k . $op . $v), - 'escape' => $escape, - ]; - } else { - $this->{$qbKey}[] = [ - 'condition' => $prefix . $k . $op . $v, + 'condition' => $v->with($this->getWhereHavingPrefix($qbKey, $type) . $condition), 'escape' => $escape, ]; + + continue; } - $prefix = $type; + $this->addWhereHavingCondition($qbKey, [ + 'condition' => $condition, + 'escape' => $escape, + ], $type); } return $this; } + /** + * @param 'QBHaving'|'QBWhere' $clause + * @param array $condition + * @param non-empty-string $type + */ + private function addWhereHavingCondition(string $clause, array $condition, string $type): void + { + $condition['condition'] = $this->getWhereHavingPrefix($clause, $type) . $condition['condition']; + + $this->{$clause}[] = $condition; + } + + /** + * @param 'QBHaving'|'QBWhere' $clause + */ + private function getWhereHavingPrefix(string $clause, string $type): string + { + return $this->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type); + } + /** * Generates a WHERE field IN('item', 'item') SQL query, * joined with 'AND' if appropriate. @@ -989,14 +1383,10 @@ protected function _whereIn(?string $key = null, $values = null, bool $not = fal $ok = $this->setBind($ok, $whereIn, $escape); - $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type); - - $whereIn = [ - 'condition' => "{$prefix}{$key}{$not} IN :{$ok}:", + $this->addWhereHavingCondition($clause, [ + 'condition' => "{$key}{$not} IN :{$ok}:", 'escape' => false, - ]; - - $this->{$clause}[] = $whereIn; + ], $type); return $this; } @@ -1014,6 +1404,20 @@ public function like($field, string $match = '', string $side = 'both', ?bool $e return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch); } + /** + * Generates grouped LIKE portions of the query joined with OR. + * + * @param list $fields + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function likeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static + { + return $this->likeAnyGroup($fields, $match, 'AND ', $side, $escape, $insensitiveSearch, __FUNCTION__); + } + /** * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'AND'. @@ -1040,6 +1444,20 @@ public function orLike($field, string $match = '', string $side = 'both', ?bool return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch); } + /** + * Generates grouped LIKE portions of the query joined with OR. + * + * @param list $fields + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orLikeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static + { + return $this->likeAnyGroup($fields, $match, 'OR ', $side, $escape, $insensitiveSearch, __FUNCTION__); + } + /** * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'OR'. @@ -1093,21 +1511,65 @@ public function orHavingLike($field, string $match = '', string $side = 'both', } /** - * Generates a NOT LIKE portion of the query. - * Separates multiple calls with 'OR'. - * - * @param array|RawSql|string $field + * Generates a NOT LIKE portion of the query. + * Separates multiple calls with 'OR'. + * + * @param array|RawSql|string $field + * + * @return $this + */ + public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + { + return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving'); + } + + /** + * @param list $fields + * + * @return $this + * + * @throws InvalidArgumentException + */ + private function likeAnyGroup(array $fields, string $match, string $type, string $side, ?bool $escape, bool $insensitiveSearch, string $caller): static + { + $this->validateLikeAnyFields($fields, $caller); + + $this->groupStartPrepare('', $type); + + foreach ($fields as $index => $field) { + $this->_like($field, $match, $index === 0 ? 'AND ' : 'OR ', $side, '', $escape, $insensitiveSearch); + } + + return $this->groupEndPrepare(); + } + + /** + * @param list $fields * - * @return $this + * @throws InvalidArgumentException */ - public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + private function validateLikeAnyFields(array $fields, string $caller): void { - return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving'); + if ($fields === [] || ! array_is_list($fields)) { + throw new InvalidArgumentException(sprintf('%s() expects $fields to be a non-empty list of field names', $caller)); + } + + foreach ($fields as $field) { + if ($field instanceof RawSql) { + continue; + } + + if (! is_string($field) || trim($field) === '') { + throw new InvalidArgumentException(sprintf('%s() expects $fields to contain only non-empty strings or RawSql instances', $caller)); + } + } } /** * @used-by like() + * @used-by likeAny() * @used-by orLike() + * @used-by orLikeAny() * @used-by notLike() * @used-by orNotLike() * @used-by havingLike() @@ -1129,7 +1591,7 @@ protected function _like($field, string $match = '', string $type = 'AND ', stri $v = $match; $insensitiveSearch = false; - $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type); + $prefix = $this->getWhereHavingPrefix($clause, $type); if ($side === 'none') { $bind = $this->setBind($field->getBindingKey(), $v, $escape); @@ -1162,13 +1624,13 @@ protected function _like($field, string $match = '', string $type = 'AND ', stri return $this; } - $prefix = $this->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type); - foreach ($keyValue as $k => $v) { if ($insensitiveSearch) { $v = mb_strtolower($v, 'UTF-8'); } + $prefix = $this->getWhereHavingPrefix($clause, $type); + if ($side === 'none') { $bind = $this->setBind($k, $v, $escape); } elseif ($side === 'before') { @@ -1190,8 +1652,6 @@ protected function _like($field, string $match = '', string $type = 'AND ', stri 'condition' => $likeStatement, 'escape' => $escape, ]; - - $prefix = $type; } return $this; @@ -1395,6 +1855,7 @@ protected function groupEndPrepare(string $clause = 'QBWhere') * @used-by _like() * @used-by whereHaving() * @used-by _whereIn() + * @used-by whereColumnHaving() * @used-by havingGroupStart() */ protected function groupGetType(string $type): string @@ -1539,6 +2000,26 @@ public function limit(?int $value = null, ?int $offset = 0) return $this; } + /** + * Locks the selected rows for update. + */ + public function lockForUpdate(): static + { + $this->QBSelectLock = self::SELECT_LOCK_FOR_UPDATE; + + return $this; + } + + /** + * Locks the selected rows in shared mode. + */ + public function sharedLock(): static + { + $this->QBSelectLock = self::SELECT_LOCK_SHARED; + + return $this; + } + /** * Sets the OFFSET value * @@ -1668,6 +2149,47 @@ public function get(?int $limit = null, int $offset = 0, bool $reset = true) return $result; } + /** + * Explains the select statement based on the other functions called + * and runs the query. + * + * @return BaseResult|false|Query|string SQL string when test mode is enabled. + */ + public function explain(bool $reset = true) + { + $this->assertExplainSupported(); + + $sql = $this->compileExplain($this->compileSelect()); + + $result = $this->testMode + ? $this->compileFinalQuery($sql) + : $this->db->query($sql, $this->binds, false); + + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result; + } + + /** + * Ensures the current driver supports explaining Query Builder selects. + */ + protected function assertExplainSupported(): void + { + } + + /** + * Compiles an execution-plan query for the current SELECT query. + */ + protected function compileExplain(string $sql): string + { + return 'EXPLAIN ' . $sql; + } + /** * Generates a platform-specific query string that counts all records in * the particular table @@ -1700,6 +2222,105 @@ public function countAll(bool $reset = true) return (int) $query->numrows; } + /** + * Determines whether the current Query Builder query would return at least one row. + * + * @return bool|string SQL string when test mode is enabled. + */ + public function exists(bool $reset = true) + { + $exists = $this->doExists($reset); + + return $exists ?? false; + } + + /** + * Determines whether the current Query Builder query would not return any rows. + * + * @return bool|string SQL string when test mode is enabled. + */ + public function doesntExist(bool $reset = true) + { + $exists = $this->doExists($reset); + + return is_string($exists) ? $exists : $exists === false; + } + + /** + * Runs an existence probe for the current Query Builder query. + * + * @return bool|string|null SQL string when test mode is enabled, or null when the query fails. + */ + protected function doExists(bool $reset = true) + { + $sql = $this->compileExists(); + + if ($this->testMode) { + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $sql; + } + + $result = $this->db->query($sql, $this->binds, false); + + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result instanceof ResultInterface ? $result->getRow() !== null : null; + } + + /** + * Compiles an existence probe for the current Query Builder query. + */ + protected function compileExists(): string + { + // ORDER BY and SELECT locks are unnecessary for checking row existence, + // and can produce invalid or surprising SQL on some drivers. + $orderBy = $this->QBOrderBy; + $limit = $this->QBLimit; + $offset = $this->QBOffset; + $selectLock = $this->QBSelectLock; + $select = $this->QBSelect; + $noEscape = $this->QBNoEscape; + $needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false; + + $this->QBOrderBy = null; + $this->QBSelectLock = null; + + if (! $needsSubquery && $this->QBLimit !== 0) { + $this->QBLimit = 1; + } + + try { + if ($needsSubquery) { + $sql = "SELECT 1 FROM (\n" . $this->compileSelect() . "\n) CI_exists"; + + $this->QBLimit = 1; + $this->QBOffset = false; + + return $this->_limit($sql . "\n"); + } + + return $this->compileSelect('SELECT 1'); + } finally { + $this->QBOrderBy = $orderBy; + $this->QBLimit = $limit; + $this->QBOffset = $offset; + $this->QBSelectLock = $selectLock; + $this->QBSelect = $select; + $this->QBNoEscape = $noEscape; + } + } + /** * Generates a platform-specific query string that counts all records * returned by an Query Builder query. @@ -1720,20 +2341,26 @@ public function countAllResults(bool $reset = true) } // We cannot use a LIMIT when getting the single row COUNT(*) result - $limit = $this->QBLimit; + $limit = $this->QBLimit; + $selectLock = $this->QBSelectLock; - $this->QBLimit = false; + $this->QBLimit = false; + $this->QBSelectLock = null; - if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { - // We need to backup the original SELECT in case DBPrefix is used - $select = $this->QBSelect; - $sql = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results"; + try { + if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { + // We need to backup the original SELECT in case DBPrefix is used + $select = $this->QBSelect; + $sql = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results"; - // Restore SELECT part - $this->QBSelect = $select; - unset($select); - } else { - $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows')); + // Restore SELECT part + $this->QBSelect = $select; + unset($select); + } else { + $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows')); + } + } finally { + $this->QBSelectLock = $selectLock; } if ($this->testMode) { @@ -2270,24 +2897,6 @@ protected function _insertBatch(string $table, array $keys, array $values): stri return str_replace('{:_table_:}', $data, $sql); } - /** - * Allows key/value pairs to be set for batch inserts - * - * @param mixed $key - * - * @return $this|null - * - * @deprecated - */ - public function setInsertBatch($key, string $value = '', ?bool $escape = null) - { - if (! is_array($key)) { - $key = [[$key => $value]]; - } - - return $this->setData($key, $escape); - } - /** * Compiles an insert query and returns the sql * @@ -2726,28 +3335,6 @@ protected function _updateBatch(string $table, array $keys, array $values): stri return str_replace('{:_table_:}', $data, $sql); } - /** - * Allows key/value pairs to be set for batch updating - * - * @param array|object $key - * - * @return $this - * - * @throws DatabaseException - * - * @deprecated - */ - public function setUpdateBatch($key, string $index = '', ?bool $escape = null) - { - if ($index !== '') { - $this->onConstraint($index); - } - - $this->setData($key, $escape); - - return $this; - } - /** * Compiles a delete string and runs "DELETE FROM table" * @@ -2988,9 +3575,41 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri */ public function increment(string $column, int $value = 1) { - $column = $this->db->protectIdentifiers($column); + return $this->incrementMany([$column], $value); + } + + /** + * Increments multiple numeric columns by the specified value(s). + * + * @param array|list $columns A list of columns or array of column => value pairs to increment. + * @param int $value The value to increment by if $columns is a list of column names. + */ + public function incrementMany(array $columns, int $value = 1): bool + { + if ($columns === []) { + throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.'); + } + + if (array_is_list($columns)) { + $columns = array_fill_keys($columns, $value); + } + + $fields = []; + + foreach ($columns as $col => $val) { + if (! is_int($val)) { + throw new TypeError(sprintf( + 'Argument #1 ($columns) must contain only int values, %s given for "%s".', + get_debug_type($val), + $col, + )); + } - $sql = $this->_update($this->QBFrom[0], [$column => "{$column} + {$value}"]); + $col = $this->db->protectIdentifiers($col); + $fields[$col] = "{$col} + {$val}"; + } + + $sql = $this->_update($this->QBFrom[0], $fields); if (! $this->testMode) { $this->resetWrite(); @@ -3008,9 +3627,41 @@ public function increment(string $column, int $value = 1) */ public function decrement(string $column, int $value = 1) { - $column = $this->db->protectIdentifiers($column); + return $this->decrementMany([$column], $value); + } + + /** + * Decrements multiple numeric columns by the specified value(s). + * + * @param array|list $columns A list of columns or array of column => value pairs to decrement. + * @param int $value The value to decrement by if $columns is a list of column names. + */ + public function decrementMany(array $columns, int $value = 1): bool + { + if ($columns === []) { + throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.'); + } + + if (array_is_list($columns)) { + $columns = array_fill_keys($columns, $value); + } + + $fields = []; + + foreach ($columns as $col => $val) { + if (! is_int($val)) { + throw new TypeError(sprintf( + 'Argument #1 ($columns) must contain only int values, %s given for "%s".', + get_debug_type($val), + $col, + )); + } + + $col = $this->db->protectIdentifiers($col); + $fields[$col] = "{$col} - {$val}"; + } - $sql = $this->_update($this->QBFrom[0], [$column => "{$column}-{$value}"]); + $sql = $this->_update($this->QBFrom[0], $fields); if (! $this->testMode) { $this->resetWrite(); @@ -3125,9 +3776,46 @@ protected function compileSelect($selectOverride = false): string $sql = $this->_limit($sql . "\n"); } + $sql .= $this->compileSelectLock(); + return $this->unionInjection($sql); } + /** + * Compile the SELECT lock clause. + */ + protected function compileSelectLock(): string + { + if ($this->QBSelectLock === null) { + return ''; + } + + if ($this->QBUnion !== []) { + throw new DatabaseException(sprintf( + 'Query Builder does not support %s() with union() or unionAll().', + $this->selectLockMethod(), + )); + } + + return match ($this->QBSelectLock) { + self::SELECT_LOCK_FOR_UPDATE => "\nFOR UPDATE", + self::SELECT_LOCK_SHARED => "\nFOR SHARE", + default => throw new DatabaseException('Query Builder has an invalid SELECT lock mode.'), + }; + } + + /** + * Returns the public method name for the current SELECT lock mode. + */ + protected function selectLockMethod(): string + { + return match ($this->QBSelectLock) { + self::SELECT_LOCK_FOR_UPDATE => 'lockForUpdate', + self::SELECT_LOCK_SHARED => 'sharedLock', + default => 'selectLock', + }; + } + /** * Checks if the ignore option is supported by * the Database Driver for the specific statement. @@ -3158,82 +3846,129 @@ protected function compileWhereHaving(string $qbKey): string { if (! empty($this->{$qbKey})) { foreach ($this->{$qbKey} as &$qbkey) { - // Is this condition already compiled? - if (is_string($qbkey)) { - continue; - } + $qbkey = $this->compileWhereHavingCondition($qbkey); + } - if ($qbkey instanceof RawSql) { - continue; - } + return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ") + . implode("\n", $this->{$qbKey}); + } - if ($qbkey['condition'] instanceof RawSql) { - $qbkey = $qbkey['condition']; + return ''; + } - continue; - } + /** + * @used-by compileWhereHaving() + * + * @param array|RawSql|string $condition + */ + private function compileWhereHavingCondition(array|RawSql|string $condition): RawSql|string + { + // Is this condition already compiled? + if (is_string($condition) || $condition instanceof RawSql) { + return $condition; + } - if ($qbkey['escape'] === false) { - $qbkey = $qbkey['condition']; + if ($condition['condition'] instanceof RawSql) { + return $condition['condition']; + } - continue; - } + if (($condition['columnComparison'] ?? false) === true) { + return $this->compileColumnComparison($condition); + } - // Split multiple conditions - $conditions = preg_split( - '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i', - $qbkey['condition'], - -1, - PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, - ); - - foreach ($conditions as &$condition) { - $op = $this->getOperator($condition); - if ( - $op === false - || preg_match( - '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?compileBetweenComparison($condition); + } - // $matches = [ - // 0 => '(test <= foo)', /* the whole thing */ - // 1 => '(', /* optional */ - // 2 => 'test', /* the field name */ - // 3 => ' <= ', /* $op */ - // 4 => 'foo', /* optional, if $op is e.g. 'IS NULL' */ - // 5 => ')' /* optional */ - // ]; - - if ($matches[4] !== '') { - $protectIdentifiers = false; - if (str_contains($matches[4], '.')) { - $protectIdentifiers = true; - } - - if (! str_contains($matches[4], ':')) { - $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers); - } - - $matches[4] = ' ' . $matches[4]; - } + if ($condition['escape'] === false) { + return $condition['condition']; + } + + return $this->compileEscapedCondition($condition['condition']); + } + + /** + * @used-by compileWhereHavingCondition() + */ + private function compileEscapedCondition(string $condition): string + { + // Split multiple conditions + $conditions = preg_split( + '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i', + $condition, + -1, + PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, + ); + + foreach ($conditions as &$condition) { + $op = $this->getOperator($condition); + if ( + $op === false + || preg_match( + '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(? '(test <= foo)', /* the whole thing */ + // 1 => '(', /* optional */ + // 2 => 'test', /* the field name */ + // 3 => ' <= ', /* $op */ + // 4 => 'foo', /* optional, if $op is e.g. 'IS NULL' */ + // 5 => ')' /* optional */ + // ]; + + if ($matches[4] !== '') { + $protectIdentifiers = false; + if (str_contains($matches[4], '.')) { + $protectIdentifiers = true; + } - $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2])) - . ' ' . trim($matches[3]) . $matches[4] . $matches[5]; + if (! str_contains($matches[4], ':')) { + $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers); } - $qbkey = implode('', $conditions); + $matches[4] = ' ' . $matches[4]; } - return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ") - . implode("\n", $this->{$qbKey}); + $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2])) + . ' ' . trim($matches[3]) . $matches[4] . $matches[5]; } - return ''; + return implode('', $conditions); + } + + /** + * @used-by compileWhereHavingCondition() + * + * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition + */ + private function compileColumnComparison(array $condition): string + { + if ($condition['escape']) { + $condition['first'] = $this->db->protectIdentifiers($condition['first'], false, true); + $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true); + } + + return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second']; + } + + /** + * @used-by compileWhereHavingCondition() + * + * @param array{betweenComparison: true, condition: string, escape: bool, key: string, lowerBind: string, not: string, upperBind: string} $condition + */ + private function compileBetweenComparison(array $condition): string + { + if ($condition['escape']) { + $condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true); + } + + return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':'; } /** @@ -3321,7 +4056,7 @@ protected function objectToArray($object) $array = []; foreach (get_object_vars($object) as $key => $val) { - if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) { + if (($val instanceof BackedEnum || ! is_object($val) || $val instanceof RawSql) && ! is_array($val)) { $array[$key] = $val; } } @@ -3414,17 +4149,19 @@ protected function resetRun(array $qbResetItems) protected function resetSelect() { $this->resetRun([ - 'QBSelect' => [], - 'QBJoin' => [], - 'QBWhere' => [], - 'QBGroupBy' => [], - 'QBHaving' => [], - 'QBOrderBy' => [], - 'QBNoEscape' => [], - 'QBDistinct' => false, - 'QBLimit' => false, - 'QBOffset' => false, - 'QBUnion' => [], + 'QBSelect' => [], + 'QBJoin' => [], + 'QBWhere' => [], + 'QBGroupBy' => [], + 'QBHaving' => [], + 'QBOrderBy' => [], + 'QBNoEscape' => [], + 'QBDistinct' => false, + 'QBLimit' => false, + 'QBOffset' => false, + 'QBSelectLock' => null, + 'QBSelectUsesAggregate' => false, + 'QBUnion' => [], ]); if ($this->db instanceof BaseConnection) { @@ -3567,18 +4304,6 @@ protected function setBind(string $key, $value = null, bool $escape = true): str return $key . '.' . $count; } - /** - * Returns a clone of a Base Builder with reset query builder values. - * - * @return $this - * - * @deprecated - */ - protected function cleanClone() - { - return (clone $this)->from([], true)->resetQuery(); - } - /** * @param mixed $value */ diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index c0cb7174dd52..a5d61290bd37 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -13,9 +13,15 @@ namespace CodeIgniter\Database; +use BackedEnum; use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\RetryableTransactionException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Events\Events; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use Exception; use ReflectionClass; use ReflectionNamedType; use ReflectionType; @@ -47,7 +53,6 @@ * @property-read bool $pretend * @property-read string $queryClass * @property-read array $reservedIdentifiers - * @property-read bool $strictOn * @property-read string $subdriver * @property-read string $swapPre * @property-read int $transDepth @@ -167,6 +172,20 @@ abstract class BaseConnection implements ConnectionInterface */ protected $DBCollat = ''; + /** + * Database session timezone + * + * false = Don't set timezone (default, backward compatible) + * true = Automatically sync with app timezone + * string = Specific timezone (offset or named timezone) + * + * Named timezones (e.g., 'America/New_York') will be automatically + * converted to offsets (e.g., '-05:00') for database compatibility. + * + * @var bool|string + */ + protected $timezone = false; + /** * Swap Prefix * @@ -188,17 +207,6 @@ abstract class BaseConnection implements ConnectionInterface */ protected $compress = false; - /** - * Strict ON flag - * - * Whether we're running in strict SQL mode. - * - * @var bool|null - * - * @deprecated 4.5.0 Will move to MySQLi\Connection. - */ - protected $strictOn; - /** * Settings for a failover connection. * @@ -214,6 +222,19 @@ abstract class BaseConnection implements ConnectionInterface */ protected $lastQuery; + /** + * The exception that would have been thrown on the last failed query + * if DBDebug were enabled. Null when the last query succeeded or when + * DBDebug is true (in which case the exception is thrown directly and + * this property is never set). + */ + protected ?DatabaseException $lastException = null; + + /** + * The first database exception that caused the current transaction to fail. + */ + protected ?DatabaseException $transFailureException = null; + /** * Connection ID * @@ -346,6 +367,20 @@ abstract class BaseConnection implements ConnectionInterface */ protected bool $transException = false; + /** + * Callbacks to run after the outermost transaction commits. + * + * @var list + */ + protected array $transCommitCallbacks = []; + + /** + * Callbacks to run after the outermost transaction rolls back. + * + * @var list + */ + protected array $transRollbackCallbacks = []; + /** * Array of table aliases. * @@ -821,8 +856,9 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s // Run the query for real try { - $exception = null; - $this->resultID = $this->simpleQuery($query->getQuery()); + $exception = null; + $this->lastException = null; + $this->resultID = $this->simpleQuery($query->getQuery()); } catch (DatabaseException $exception) { $this->resultID = false; } @@ -831,7 +867,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s $query->setDuration($startTime, $startTime); // This will trigger a rollback if transactions are being used - $this->handleTransStatus(); + $this->handleTransStatus($exception ?? $this->lastException); if ( $this->DBDebug @@ -860,11 +896,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s Events::trigger('DBQuery', $query); if ($exception instanceof DatabaseException) { - throw new DatabaseException( - $exception->getMessage(), - $exception->getCode(), - $exception, - ); + throw $exception; } return false; @@ -976,13 +1008,15 @@ public function transComplete(): bool // The query() function will set this flag to FALSE in the event that a query failed if ($this->transStatus === false || $this->transFailure === true) { - $this->transRollback(); - - // If we are NOT running in strict mode, we will reset - // the _trans_status flag so that subsequent groups of - // transactions will be permitted. - if ($this->transStrict === false) { - $this->transStatus = true; + try { + $this->transRollback(); + } finally { + // If we are NOT running in strict mode, we will reset + // the _trans_status flag so that subsequent groups of + // transactions will be permitted. + if ($this->transStrict === false) { + $this->transStatus = true; + } } return false; @@ -999,6 +1033,150 @@ public function transStatus(): bool return $this->transStatus; } + /** + * Checks whether this connection is inside an active transaction. + */ + public function inTransaction(): bool + { + return $this->transDepth > 0; + } + + /** + * Register a callback to run after the outermost transaction commits. + * + * If no transaction is active, the callback runs immediately. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterCommit(callable $callback): static + { + if ($this->transDepth === 0) { + $callback(); + + return $this; + } + + $this->transCommitCallbacks[] = $callback; + + return $this; + } + + /** + * Register a callback to run after the outermost transaction rolls back. + * + * If no transaction is active, the callback is not run. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterRollback(callable $callback): static + { + if ($this->transDepth === 0) { + return $this; + } + + $this->transRollbackCallbacks[] = $callback; + + return $this; + } + + /** + * Run the callback inside a transaction. + * + * @template TReturn + * + * @param callable(self): TReturn $callback + * @param positive-int $attempts + * @param bool|null $transException Temporarily override transaction exception mode. + * @param bool $resetTransStatus Reset transaction status before an outermost transaction starts. + * + * @return false|TReturn + */ + public function transaction( + callable $callback, + int $attempts = 1, + ?bool $transException = null, + bool $resetTransStatus = false, + ): mixed { + if ($attempts < 1) { + throw new InvalidArgumentException('Transaction attempts must be a positive integer.'); + } + + $restoreTransException = $transException !== null; + $previousTransException = $this->transException; + + if ($restoreTransException) { + $this->transException = $transException; + } + + try { + if (! $this->transEnabled) { + return $callback($this); + } + + $outermostTransaction = $this->transDepth === 0; + + if ($resetTransStatus && $outermostTransaction) { + $this->resetTransStatus(); + } + + $attempts = $outermostTransaction ? $attempts : 1; + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + if (! $this->transBegin()) { + return false; + } + + try { + $result = $callback($this); + } catch (Throwable $e) { + try { + $this->transRollback(); + } catch (Throwable $rollbackException) { + log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e); + + throw $rollbackException; + } finally { + if ($this->transDepth > 0) { + $this->transStatus = false; + } elseif ($this->transStrict === false) { + $this->transStatus = true; + } + } + + if ($this->transDepth === 0 && $e instanceof RetryableTransactionException && $attempt < $attempts) { + $this->prepareTransactionRetry(); + + continue; + } + + throw $e; + } + + if (! $this->transComplete()) { + if ($this->transDepth === 0 && $this->transFailureException instanceof RetryableTransactionException && $attempt < $attempts) { + $this->prepareTransactionRetry(); + + continue; + } + + return false; + } + + return $result; + } + + return false; + } finally { + if ($restoreTransException) { + $this->transException = $previousTransException; + } + } + } + /** * Begin Transaction */ @@ -1022,7 +1200,8 @@ public function transBegin(bool $testMode = false): bool // Reset the transaction failure flag. // If the $testMode flag is set to TRUE transactions will be rolled back // even if the queries produce a successful result. - $this->transFailure = $testMode; + $this->transFailure = $testMode; + $this->transFailureException = null; if ($this->_transBegin()) { $this->transDepth++; @@ -1046,6 +1225,11 @@ public function transCommit(): bool if ($this->transDepth > 1 || $this->_transCommit()) { $this->transDepth--; + if ($this->transDepth === 0) { + $this->transRollbackCallbacks = []; + $this->runTransCommitCallbacks(); + } + return true; } @@ -1065,6 +1249,11 @@ public function transRollback(): bool if ($this->transDepth > 1 || $this->_transRollback()) { $this->transDepth--; + if ($this->transDepth === 0) { + $this->transCommitCallbacks = []; + $this->runTransRollbackCallbacks(); + } + return true; } @@ -1086,10 +1275,47 @@ public function resetTransStatus(): static * * @internal This method is for internal database component use only */ - public function handleTransStatus(): void + public function handleTransStatus(?DatabaseException $exception = null): void { if ($this->transDepth !== 0) { $this->transStatus = false; + $this->transFailureException ??= $exception; + } + } + + /** + * Reset transaction state that should not leak into a retry attempt. + */ + protected function prepareTransactionRetry(): void + { + $this->transStatus = true; + $this->transFailureException = null; + $this->lastException = null; + } + + /** + * Run and clear callbacks registered for a successful transaction commit. + */ + protected function runTransCommitCallbacks(): void + { + $callbacks = $this->transCommitCallbacks; + $this->transCommitCallbacks = []; + + foreach ($callbacks as $callback) { + $callback(); + } + } + + /** + * Run and clear callbacks registered for a transaction rollback. + */ + protected function runTransRollbackCallbacks(): void + { + $callbacks = $this->transRollbackCallbacks; + $this->transRollbackCallbacks = []; + + foreach ($callbacks as $callback) { + $callback(); } } @@ -1543,6 +1769,10 @@ public function escape($str) return array_map($this->escape(...), $str); } + if ($str instanceof BackedEnum) { + $str = $str->value; + } + if ($str instanceof Stringable) { if ($str instanceof RawSql) { return $str->__toString(); @@ -1972,6 +2202,64 @@ public function isWriteType($sql): bool */ abstract public function error(): array; + /** + * Returns the exception that would have been thrown on the last failed + * query if DBDebug were enabled. Returns null if the last query succeeded + * or if DBDebug is true (in which case the exception is always thrown + * directly and this method will always return null). + */ + public function getLastException(): ?DatabaseException + { + return $this->lastException; + } + + /** + * Sets the exception for the last failed database operation. + * + * @internal This method is for internal database component use only. + */ + public function setLastException(?DatabaseException $exception): void + { + $this->lastException = $exception; + } + + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + return false; + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return false; + } + + /** + * Creates the appropriate database exception for a native database error. + * + * @internal This method is for internal database component use only. + */ + public function createDatabaseException( + string $message, + int|string $code = 0, + ?Throwable $previous = null, + ): DatabaseException { + if ($this->isUniqueConstraintViolation($code, $message)) { + return new UniqueConstraintViolationException($message, $code, $previous); + } + + if ($this->isRetryableTransactionErrorCode($code)) { + return new RetryableTransactionException($message, $code, $previous); + } + + return new DatabaseException($message, $code, $previous); + } + /** * Insert ID * @@ -2056,6 +2344,68 @@ protected function _enableForeignKeyChecks() return ''; } + /** + * Converts a named timezone to an offset string. + * + * Converts timezone identifiers (e.g., 'America/New_York') to offset strings + * (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all + * databases have timezone tables loaded, but all support offset notation. + * + * @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris') + * + * @return string Offset string (e.g., '+00:00', '-05:00', '+01:00') + */ + protected function convertTimezoneToOffset(string $timezone): string + { + // If it's already an offset, return as-is + if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) { + return $timezone; + } + + try { + $offset = Time::now($timezone)->getOffset(); + + // Convert offset seconds to +-HH:MM format + $hours = (int) ($offset / 3600); + $minutes = abs((int) (($offset % 3600) / 60)); + + return sprintf('%+03d:%02d', $hours, $minutes); + } catch (Exception $e) { + // If timezone conversion fails, log and return UTC + log_message('error', "Invalid timezone '{$timezone}'. Falling back to UTC. {$e->getMessage()}."); + + return '+00:00'; + } + } + + /** + * Gets the timezone string to use for database session. + * + * Handles the timezone configuration logic: + * - false: Don't set timezone (returns null) + * - true: Auto-sync with app timezone from config + * - string: Use specific timezone (converts named timezones to offsets) + * + * @return string|null The timezone offset string, or null if timezone should not be set + */ + protected function getSessionTimezone(): ?string + { + if ($this->timezone === false) { + return null; + } + + // Auto-sync with app timezone + if ($this->timezone === true) { + $appConfig = config('App'); + $timezone = $appConfig->appTimezone; + } else { + // Use specific timezone from config + $timezone = $this->timezone; + } + + return $this->convertTimezoneToOffset($timezone); + } + /** * Accessor for properties if they exist. * diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index 540e9c15161c..5eec202dd197 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -14,10 +14,12 @@ namespace CodeIgniter\Database; use ArgumentCountError; +use BackedEnum; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\BadMethodCallException; use ErrorException; +use Throwable; /** * @template TConnection @@ -49,6 +51,11 @@ abstract class BasePreparedQuery implements PreparedQueryInterface */ protected $errorString; + /** + * The typed exception for the last failed prepared query, if any. + */ + protected ?DatabaseException $databaseException = null; + /** * Holds the prepared query object * that is cloned during execute. @@ -116,13 +123,21 @@ abstract public function _prepare(string $sql, array $options = []); */ public function execute(...$data) { + foreach ($data as $key => $value) { + if ($value instanceof BackedEnum) { + $data[$key] = $value->value; + } + } + // Execute the Query. $startTime = microtime(true); try { $exception = null; - $result = $this->_execute($data); - } catch (ArgumentCountError|ErrorException $exception) { + $this->db->setLastException(null); + $this->databaseException = null; + $result = $this->_execute($data); + } catch (ArgumentCountError|DatabaseException|ErrorException $exception) { $result = false; } @@ -133,8 +148,10 @@ public function execute(...$data) if ($result === false) { $query->setDuration($startTime, $startTime); + $databaseException = $this->createDatabaseException($exception); + // This will trigger a rollback if transactions are being used - $this->db->handleTransStatus(); + $this->db->handleTransStatus($databaseException); if ($this->db->DBDebug) { // We call this function in order to roll-back queries @@ -154,8 +171,8 @@ public function execute(...$data) // Let others do something with this query. Events::trigger('DBQuery', $query); - if ($exception !== null) { - throw new DatabaseException($exception->getMessage(), $exception->getCode(), $exception); + if ($databaseException instanceof DatabaseException) { + throw $databaseException; } return false; @@ -164,6 +181,8 @@ public function execute(...$data) // Let others do something with this query. Events::trigger('DBQuery', $query); + $this->db->setLastException($databaseException); + return false; } @@ -196,6 +215,34 @@ abstract public function _execute(array $data): bool; */ abstract public function _getResult(); + /** + * Creates the database exception for a failed prepared query. + */ + private function createDatabaseException(?Throwable $previous): ?DatabaseException + { + if ($previous instanceof DatabaseException) { + return $previous; + } + + if ($this->databaseException instanceof DatabaseException) { + return $this->databaseException; + } + + if ($previous instanceof Throwable) { + return $this->db->createDatabaseException( + $previous->getMessage(), + $previous->getCode(), + $previous, + ); + } + + if ($this->errorString === null || $this->errorString === '') { + return null; + } + + return $this->db->createDatabaseException($this->errorString, $this->errorCode); + } + /** * Explicitly closes the prepared statement. * diff --git a/system/Database/Config.php b/system/Database/Config.php index 413d0b3b5de8..8e53b844a6c3 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -16,6 +16,7 @@ use CodeIgniter\Config\BaseConfig; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database as DbConfig; +use Throwable; /** * @see \CodeIgniter\Database\ConfigTest @@ -61,7 +62,7 @@ public static function connect($group = null, bool $getShared = true) $dbConfig = config(DbConfig::class); if ($group === null) { - $group = (ENVIRONMENT === 'testing') ? 'tests' : $dbConfig->defaultGroup; + $group = service('environment')->isTesting() ? 'tests' : $dbConfig->defaultGroup; } assert(is_string($group)); @@ -184,7 +185,12 @@ public static function cleanupForWorkerMode(): void log_message('error', "Uncommitted transaction detected in database group '{$group}'. Transactions must be completed before request ends."); while ($connection->transDepth > 0) { - $connection->transRollback(); + try { + $connection->transRollback(); + } catch (Throwable $e) { + log_message('critical', $e->getMessage()); + break; + } } } diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 15fd63e1c7aa..0bf03e529d2d 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -115,6 +115,48 @@ public function query(string $sql, $binds = null); */ public function simpleQuery(string $sql); + /** + * Checks whether this connection is inside an active CodeIgniter-managed transaction. + */ + public function inTransaction(): bool; + + /** + * Register a callback to run after the outermost transaction commits. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterCommit(callable $callback): static; + + /** + * Register a callback to run after the outermost transaction rolls back. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterRollback(callable $callback): static; + + /** + * Run the callback inside a transaction. + * + * @template TReturn + * + * @param callable(self): TReturn $callback + * @param positive-int $attempts + * @param bool|null $transException Temporarily override transaction exception mode. + * @param bool $resetTransStatus Reset transaction status before an outermost transaction starts. + * + * @return false|TReturn + */ + public function transaction( + callable $callback, + int $attempts = 1, + ?bool $transException = null, + bool $resetTransStatus = false, + ): mixed; + /** * Returns an instance of the query builder for this connection. * diff --git a/system/Database/Exceptions/DataException.php b/system/Database/Exceptions/DataException.php index 7cd9f526e6c2..c61f4debe81f 100644 --- a/system/Database/Exceptions/DataException.php +++ b/system/Database/Exceptions/DataException.php @@ -73,6 +73,16 @@ public static function forInvalidAllowedFields(string $model) return new static(lang('Database.invalidAllowedFields', [$model])); } + /** + * @param list $fields + * + * @return DataException + */ + public static function forDisallowedFields(string $model, array $fields) + { + return new static(lang('Database.disallowedFields', [$model, implode(', ', $fields)])); + } + /** * @return DataException */ diff --git a/system/Database/Exceptions/DatabaseException.php b/system/Database/Exceptions/DatabaseException.php index 77b170e0b407..48e335095910 100644 --- a/system/Database/Exceptions/DatabaseException.php +++ b/system/Database/Exceptions/DatabaseException.php @@ -15,9 +15,34 @@ use CodeIgniter\Exceptions\HasExitCodeInterface; use CodeIgniter\Exceptions\RuntimeException; +use Throwable; class DatabaseException extends RuntimeException implements ExceptionInterface, HasExitCodeInterface { + /** + * Native code returned by the database driver. + */ + protected int|string $databaseCode = 0; + + /** + * @param int|string $code Native database code (e.g. 1062, 23505, 23000/2601) + */ + public function __construct(string $message = '', int|string $code = 0, ?Throwable $previous = null) + { + $this->databaseCode = $code; + + // Keep Throwable::getCode() behavior BC-friendly for non-int DB codes. + parent::__construct($message, is_int($code) ? $code : 0, $previous); + } + + /** + * Returns the native code from the database driver. + */ + public function getDatabaseCode(): int|string + { + return $this->databaseCode; + } + public function getExitCode(): int { return EXIT_DATABASE; diff --git a/system/Database/Exceptions/RetryableTransactionException.php b/system/Database/Exceptions/RetryableTransactionException.php new file mode 100644 index 000000000000..ae9f7147b4d8 --- /dev/null +++ b/system/Database/Exceptions/RetryableTransactionException.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 CodeIgniter\Database\Exceptions; + +/** + * Thrown when a transaction failure may succeed if the whole transaction is retried. + */ +class RetryableTransactionException extends DatabaseException +{ +} diff --git a/system/Database/Exceptions/UniqueConstraintViolationException.php b/system/Database/Exceptions/UniqueConstraintViolationException.php new file mode 100644 index 000000000000..148a0e0bc9bf --- /dev/null +++ b/system/Database/Exceptions/UniqueConstraintViolationException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Exceptions; + +class UniqueConstraintViolationException extends DatabaseException +{ +} diff --git a/system/Database/Forge.php b/system/Database/Forge.php index f6eb617fd389..f8a5ffa0a67e 100644 --- a/system/Database/Forge.php +++ b/system/Database/Forge.php @@ -108,15 +108,6 @@ class Forge */ protected $createTableStr = "%s %s (%s\n)"; - /** - * CREATE TABLE IF statement - * - * @var bool|string - * - * @deprecated This is no longer used. - */ - protected $createTableIfStr = 'CREATE TABLE IF NOT EXISTS'; - /** * CREATE TABLE keys flag * @@ -565,7 +556,7 @@ public function createTable(string $table, bool $ifNotExists = false, array $att return true; } - $sql = $this->_createTable($table, false, $attributes); + $sql = $this->_createTable($table, $attributes); if (($result = $this->db->query($sql)) !== false) { if (isset($this->db->dataCache['table_names']) && ! in_array($table, $this->db->dataCache['table_names'], true)) { @@ -586,13 +577,11 @@ public function createTable(string $table, bool $ifNotExists = false, array $att } /** - * @param array $attributes Table attributes + * @param array $attributes Table attributes * * @return string SQL string - * - * @deprecated $ifNotExists is no longer used, and will be removed. */ - protected function _createTable(string $table, bool $ifNotExists, array $attributes) + protected function _createTable(string $table, array $attributes) { $processedFields = $this->_processFields(true); diff --git a/system/Database/MigrationRunner.php b/system/Database/MigrationRunner.php index df5ee049f0b7..46c3c76d0a90 100644 --- a/system/Database/MigrationRunner.php +++ b/system/Database/MigrationRunner.php @@ -446,9 +446,10 @@ public function findMigrations(): array { $namespaces = $this->namespace !== null ? [$this->namespace] : array_keys(service('autoloader')->getNamespace()); $migrations = []; + $detector = service('environment'); foreach ($namespaces as $namespace) { - if (ENVIRONMENT !== 'testing' && $namespace === 'Tests\Support') { + if (! $detector->isTesting() && $namespace === 'Tests\Support') { continue; } @@ -985,7 +986,7 @@ protected function migrate($direction, $migration): bool $instance = new $class(Database::forge($this->db)); $group = $instance->getDBGroup() ?? $this->group; - if (ENVIRONMENT !== 'testing' && $group === 'tests' && $this->groupFilter !== 'tests') { + if (! service('environment')->isTesting() && $group === 'tests' && $this->groupFilter !== 'tests') { // @codeCoverageIgnoreStart $this->groupSkip = true; diff --git a/system/Database/MySQLi/Builder.php b/system/Database/MySQLi/Builder.php index a9e248fc4584..9992ef860926 100644 --- a/system/Database/MySQLi/Builder.php +++ b/system/Database/MySQLi/Builder.php @@ -58,6 +58,31 @@ protected function _fromTables(): string return implode(', ', $this->QBFrom); } + /** + * Compile the SELECT lock clause. + */ + protected function compileSelectLock(): string + { + if ($this->QBSelectLock === null) { + return ''; + } + + foreach ($this->QBFrom as $value) { + if (str_starts_with($value, '(SELECT')) { + throw new DatabaseException(sprintf( + 'MySQLi does not support %s() with fromSubquery().', + $this->selectLockMethod(), + )); + } + } + + if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) { + return "\nLOCK IN SHARE MODE"; + } + + return parent::compileSelectLock(); + } + /** * Generates a platform-specific batch update string from the supplied data */ diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 116acb7eaf9e..d317dc163a58 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -92,6 +92,29 @@ class Connection extends BaseConnection */ public $foundRows = false; + /** + * Strict SQL mode + */ + protected bool $strictOn = false; + + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + // ER_DUP_ENTRY: duplicate key value. + return $code === 1062; + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + // ER_LOCK_DEADLOCK: InnoDB rolls back the full transaction. + return $code === 1213; + } + /** * Connect to the database. * @@ -123,27 +146,32 @@ public function connect(bool $persistent = false) $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1); } - if ($this->strictOn !== null) { - if ($this->strictOn) { - $this->mysqli->options( - MYSQLI_INIT_COMMAND, - "SET SESSION sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')", - ); - } else { - $this->mysqli->options( - MYSQLI_INIT_COMMAND, - "SET SESSION sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( - @@sql_mode, - 'STRICT_ALL_TABLES,', ''), - ',STRICT_ALL_TABLES', ''), - 'STRICT_ALL_TABLES', ''), - 'STRICT_TRANS_TABLES,', ''), - ',STRICT_TRANS_TABLES', ''), - 'STRICT_TRANS_TABLES', '')", - ); - } + $initCommands = []; + + if ($this->strictOn) { + $initCommands[] = "sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')"; + } else { + $initCommands[] = "sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + @@sql_mode, + 'STRICT_ALL_TABLES,', ''), + ',STRICT_ALL_TABLES', ''), + 'STRICT_ALL_TABLES', ''), + 'STRICT_TRANS_TABLES,', ''), + ',STRICT_TRANS_TABLES', ''), + 'STRICT_TRANS_TABLES', '')"; } + // Set session timezone if configured + $timezoneOffset = $this->getSessionTimezone(); + if ($timezoneOffset !== null) { + $initCommands[] = "time_zone = '{$timezoneOffset}'"; + } + + $this->mysqli->options( + MYSQLI_INIT_COMMAND, + 'SET SESSION ' . implode(', ', $initCommands), + ); + if (is_array($this->encrypt)) { $ssl = []; @@ -306,9 +334,13 @@ protected function execute(string $sql) 'trace' => render_backtrace($e->getTrace()), ]); + $exception = $this->createDatabaseException($e->getMessage(), $e->getCode(), $e); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; } return false; diff --git a/system/Database/MySQLi/PreparedQuery.php b/system/Database/MySQLi/PreparedQuery.php index 05ef5f151b7b..05ce7595574d 100644 --- a/system/Database/MySQLi/PreparedQuery.php +++ b/system/Database/MySQLi/PreparedQuery.php @@ -14,7 +14,6 @@ namespace CodeIgniter\Database\MySQLi; use CodeIgniter\Database\BasePreparedQuery; -use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Exceptions\BadMethodCallException; use mysqli; use mysqli_result; @@ -49,7 +48,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery $this->errorString = $this->db->mysqli->error; if ($this->db->DBDebug) { - throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + throw $this->db->createDatabaseException($this->errorString, $this->errorCode); } } @@ -93,14 +92,29 @@ public function _execute(array $data): bool } try { - return $this->statement->execute(); + $result = $this->statement->execute(); } catch (mysqli_sql_exception $e) { + $this->errorCode = $e->getCode(); + $this->errorString = $e->getMessage(); + $this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode, $e); + if ($this->db->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $this->databaseException; } return false; } + + if ($result === false) { + $this->errorCode = $this->statement->errno; + $this->errorString = $this->statement->error; + + if ($this->db->DBDebug) { + throw $this->db->createDatabaseException($this->errorString, $this->errorCode); + } + } + + return $result; } /** diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 25af517dc959..2addf6b8cb2f 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -213,6 +213,38 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string return $sql . ' OFFSET ' . $offset . ' ROWS FETCH NEXT ' . $this->QBLimit . ' ROWS ONLY'; } + /** + * Compile the SELECT lock clause. + */ + protected function compileSelectLock(): string + { + if ($this->QBSelectLock === null) { + return ''; + } + + if ($this->QBSelectLock === self::SELECT_LOCK_SHARED) { + throw new DatabaseException('OCI8 does not support sharedLock().'); + } + + if ($this->QBLimit !== false || $this->QBOffset) { + throw new DatabaseException('OCI8 does not support lockForUpdate() with limit() or offset().'); + } + + if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) { + throw new DatabaseException('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); + } + + return parent::compileSelectLock(); + } + + /** + * Ensures the current driver supports explaining Query Builder selects. + */ + protected function assertExplainSupported(): never + { + throw new DatabaseException('OCI8 does not support explain().'); + } + /** * Generates a platform-specific batch update string from the supplied data */ diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index dc884588a251..1fcbf9e74824 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -114,6 +114,23 @@ class Connection extends BaseConnection */ public $lastInsertedTableName; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + // ORA-00001: unique constraint violated. + return (int) $code === 1; + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return in_array((int) $code, [60, 8177], true); + } + /** * confirm DSN format. */ @@ -145,9 +162,19 @@ public function connect(bool $persistent = false) $func = $persistent ? 'oci_pconnect' : 'oci_connect'; - return ($this->charset === '') + $this->connID = ($this->charset === '') ? $func($this->username, $this->password, $this->DSN) : $func($this->username, $this->password, $this->DSN, $this->charset); + + // Set session timezone if configured and connection is successful + if ($this->connID !== false) { + $timezoneOffset = $this->getSessionTimezone(); + if ($timezoneOffset !== null) { + $this->simpleQuery("ALTER SESSION SET TIME_ZONE = '{$timezoneOffset}'"); + } + } + + return $this->connID; } /** @@ -226,10 +253,24 @@ protected function execute(string $sql) oci_set_prefetch($this->stmtId, 1000); - $result = oci_execute($this->stmtId, $this->commitMode) ? $this->stmtId : false; + $result = oci_execute($this->stmtId, $this->commitMode) ? $this->stmtId : false; + + if ($result === false) { + $error = $this->error(); + $exception = $this->createDatabaseException((string) $error['message'], $error['code']); + + if ($this->DBDebug) { + throw $exception; + } + + $this->lastException = $exception; + + return false; + } + $insertTableName = $this->parseInsertTableName($sql); - if ($result && $insertTableName !== '') { + if ($insertTableName !== '') { $this->lastInsertedTableName = $insertTableName; } @@ -244,9 +285,14 @@ protected function execute(string $sql) 'trace' => render_backtrace($trace), ]); + $error = $this->error(); + $exception = $this->createDatabaseException((string) $error['message'], $error['code'], $e); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; } return false; @@ -605,25 +651,25 @@ protected function bindParams($params) */ public function error(): array { - // oci_error() returns an array that already contains - // 'code' and 'message' keys, but it can return false - // if there was no error .... - $error = oci_error(); + // oci_error() is resource-specific: check each resource in priority order + // and return the first one that actually has an error. This ensures that + // e.g. oci_parse() failures (error on connID) are found even when stmtId + // holds a stale valid resource from the previous successful query. $resources = [$this->cursorId, $this->stmtId, $this->connID]; foreach ($resources as $resource) { if (is_resource($resource)) { $error = oci_error($resource); - break; + + if (is_array($error)) { + return $error; + } } } - return is_array($error) - ? $error - : [ - 'code' => '', - 'message' => '', - ]; + $error = oci_error(); + + return is_array($error) ? $error : ['code' => '', 'message' => '']; } public function insertID(): int diff --git a/system/Database/OCI8/Forge.php b/system/Database/OCI8/Forge.php index 8f71169115ce..8e046aa7d2be 100644 --- a/system/Database/OCI8/Forge.php +++ b/system/Database/OCI8/Forge.php @@ -34,15 +34,6 @@ class Forge extends BaseForge */ protected $createDatabaseStr = false; - /** - * CREATE TABLE IF statement - * - * @var false - * - * @deprecated This is no longer used. - */ - protected $createTableIfStr = false; - /** * DROP TABLE IF EXISTS statement * diff --git a/system/Database/OCI8/PreparedQuery.php b/system/Database/OCI8/PreparedQuery.php index 390ed938fc1c..c7ca22543bc6 100644 --- a/system/Database/OCI8/PreparedQuery.php +++ b/system/Database/OCI8/PreparedQuery.php @@ -16,6 +16,7 @@ use CodeIgniter\Database\BasePreparedQuery; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Exceptions\BadMethodCallException; +use ErrorException; use OCILob; /** @@ -55,7 +56,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery $this->errorString = $error['message'] ?? ''; if ($this->db->DBDebug) { - throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + throw $this->db->createDatabaseException($this->errorString, $this->errorCode); } } @@ -86,10 +87,28 @@ public function _execute(array $data): bool } } - $result = oci_execute($this->statement, $this->db->commitMode); + try { + $result = oci_execute($this->statement, $this->db->commitMode); + } catch (ErrorException $e) { + $databaseException = $this->setDatabaseExceptionFromStatement($e); - if ($binaryData instanceof OCILob) { - $binaryData->free(); + if ($this->db->DBDebug) { + throw $databaseException; + } + + return false; + } finally { + if ($binaryData instanceof OCILob) { + $binaryData->free(); + } + } + + if ($result === false) { + $databaseException = $this->setDatabaseExceptionFromStatement(); + + if ($this->db->DBDebug) { + throw $databaseException; + } } if ($result && $this->lastInsertTableName !== '') { @@ -117,6 +136,19 @@ protected function _close(): bool return oci_free_statement($this->statement); } + /** + * Captures the native OCI statement error for shared database exception classification. + */ + private function setDatabaseExceptionFromStatement(?ErrorException $previous = null): DatabaseException + { + $error = oci_error($this->statement); + $this->errorCode = $error['code'] ?? 0; + $this->errorString = $error['message'] ?? $previous?->getMessage() ?? ''; + $this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode, $previous); + + return $this->databaseException; + } + /** * Replaces the ? placeholders with :0, :1, etc parameters for use * within the prepared query. diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 4cf0bde40060..7953a1fe018c 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -19,6 +19,7 @@ use CodeIgniter\Database\Query; use CodeIgniter\Database\RawSql; use CodeIgniter\Exceptions\InvalidArgumentException; +use TypeError; /** * Builder for Postgre @@ -61,6 +62,25 @@ protected function compileIgnore(string $statement) return $sql; } + /** + * Compile the SELECT lock clause. + */ + protected function compileSelectLock(): string + { + if ($this->QBSelectLock === null) { + return ''; + } + + if ($this->QBDistinct || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBSelectUsesAggregate) { + throw new DatabaseException(sprintf( + 'Postgre does not support %s() with distinct(), groupBy(), having(), or aggregate helper selections.', + $this->selectLockMethod(), + )); + } + + return parent::compileSelectLock(); + } + /** * ORDER BY * @@ -89,17 +109,37 @@ public function orderBy(string $orderBy, string $direction = '', ?bool $escape = } /** - * Increments a numeric column by the specified value. - * - * @return bool + * Increments multiple numeric columns by the specified value(s). * - * @throws DatabaseException + * @param array|list $columns A list of columns or array of column => value pairs to increment. + * @param int $value The value to increment by if $columns is a list of column names. */ - public function increment(string $column, int $value = 1) + public function incrementMany(array $columns, int $value = 1): bool { - $column = $this->db->protectIdentifiers($column); + if ($columns === []) { + throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.'); + } - $sql = $this->_update($this->QBFrom[0], [$column => "CAST({$column} AS numeric) + {$value}"]); + if (array_is_list($columns)) { + $columns = array_fill_keys($columns, $value); + } + + $fields = []; + + foreach ($columns as $col => $val) { + if (! is_int($val)) { + throw new TypeError(sprintf( + 'Argument #1 ($columns) must contain only int values, %s given for "%s".', + get_debug_type($val), + $col, + )); + } + + $col = $this->db->protectIdentifiers($col); + $fields[$col] = "CAST({$col} AS numeric) + {$val}"; + } + + $sql = $this->_update($this->QBFrom[0], $fields); if (! $this->testMode) { $this->resetWrite(); @@ -111,17 +151,37 @@ public function increment(string $column, int $value = 1) } /** - * Decrements a numeric column by the specified value. + * Decrements multiple numeric columns by the specified value(s). * - * @return bool - * - * @throws DatabaseException + * @param array|list $columns A list of columns or array of column => value pairs to decrement. + * @param int $value The value to decrement by if $columns is a list of column names. */ - public function decrement(string $column, int $value = 1) + public function decrementMany(array $columns, int $value = 1): bool { - $column = $this->db->protectIdentifiers($column); + if ($columns === []) { + throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.'); + } + + if (array_is_list($columns)) { + $columns = array_fill_keys($columns, $value); + } + + $fields = []; + + foreach ($columns as $col => $val) { + if (! is_int($val)) { + throw new TypeError(sprintf( + 'Argument #1 ($columns) must contain only int values, %s given for "%s".', + get_debug_type($val), + $col, + )); + } + + $col = $this->db->protectIdentifiers($col); + $fields[$col] = "CAST({$col} AS numeric) - {$val}"; + } - $sql = $this->_update($this->QBFrom[0], [$column => "CAST({$column} AS numeric) - {$value}"]); + $sql = $this->_update($this->QBFrom[0], $fields); if (! $this->testMode) { $this->resetWrite(); diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 4c3358a4b470..62416a16067a 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Postgre; +use BackedEnum; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\RawSql; @@ -56,6 +57,28 @@ class Connection extends BaseConnection protected $sslmode; protected $service; + /** + * Last failed query result, used by error() to extract the SQLSTATE + * via pg_result_error_field() without string parsing. + */ + private ?PgSqlResult $lastFailedResult = null; + + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + return $code === '23505'; + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return in_array($code, ['40001', '40P01'], true); + } + /** * Connect to the database. * @@ -97,6 +120,12 @@ public function connect(bool $persistent = false) throw new DatabaseException($error); } + + // Set session timezone if configured + $timezoneOffset = $this->getSessionTimezone(); + if ($timezoneOffset !== null) { + $this->simpleQuery("SET TIME ZONE '{$timezoneOffset}'"); + } } return $this->connID; @@ -196,8 +225,10 @@ public function getVersion(): string */ protected function execute(string $sql) { + $this->lastFailedResult = null; + try { - return pg_query($this->connID, $sql); + $sent = pg_send_query($this->connID, $sql); } catch (ErrorException $e) { $trace = array_slice($e->getTrace(), 2); // remove the call to error handler @@ -208,11 +239,94 @@ protected function execute(string $sql) 'trace' => render_backtrace($trace), ]); + $exception = new DatabaseException($e->getMessage(), 0, $e); + + if ($this->DBDebug) { + throw $exception; + } + + $this->lastException = $exception; + + return false; + } + + if ($sent === false) { + return $this->handleConnectionError(); + } + + $result = pg_get_result($this->connID); + + if ($result === false) { + return $this->handleConnectionError(); + } + + // Drain all results; return the last one (pg_query() semantics) or the first error. + $lastResult = $result; + $failedResult = pg_result_status($result) === PGSQL_FATAL_ERROR ? $result : null; + + while (($next = pg_get_result($this->connID)) !== false) { + $lastResult = $next; + + if (! $failedResult instanceof PgSqlResult && pg_result_status($next) === PGSQL_FATAL_ERROR) { + $failedResult = $next; + } + } + + if ($failedResult instanceof PgSqlResult) { + $sqlstate = (string) pg_result_error_field($failedResult, PGSQL_DIAG_SQLSTATE); + $message = (string) pg_result_error($failedResult); + $trace = debug_backtrace(); + $first = array_shift($trace); + + $this->lastFailedResult = $failedResult; + + // Log only the first line; pg_result_error() appends "LINE N: ..." context. + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => explode("\n", $message)[0], + 'exFile' => clean_path($first['file']), + 'exLine' => $first['line'], + 'trace' => render_backtrace($trace), + ]); + + $exception = $this->createDatabaseException($message, $sqlstate); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; + + return false; } + return $lastResult; + } + + /** + * Logs a connection-level error from pg_last_error(), stores or throws a + * DatabaseException, and returns false. + */ + private function handleConnectionError(): false + { + $message = pg_last_error($this->connID); + $trace = debug_backtrace(); + $first = array_shift($trace); + + log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'message' => $message, + 'exFile' => clean_path($first['file']), + 'exLine' => $first['line'], + 'trace' => render_backtrace($trace), + ]); + + $exception = new DatabaseException($message); + + if ($this->DBDebug) { + throw $exception; + } + + $this->lastException = $exception; + return false; } @@ -251,6 +365,10 @@ public function escape($str) $this->initialize(); } + if ($str instanceof BackedEnum) { + $str = $str->value; + } + if ($str instanceof Stringable) { if ($str instanceof RawSql) { return $str->__toString(); @@ -473,9 +591,19 @@ protected function _enableForeignKeyChecks() */ public function error(): array { + if ($this->lastFailedResult instanceof PgSqlResult) { + return [ + 'code' => (string) pg_result_error_field($this->lastFailedResult, PGSQL_DIAG_SQLSTATE), + 'message' => (string) pg_result_error($this->lastFailedResult), + ]; + } + + // Fallback for connection-level errors: no SQLSTATE outside a result resource. + $message = pg_last_error($this->connID); + return [ - 'code' => '', - 'message' => pg_last_error($this->connID), + 'code' => $message !== '' ? '08006' : 0, + 'message' => $message, ]; } @@ -578,12 +706,50 @@ protected function setClientEncoding(string $charset): bool return pg_set_client_encoding($this->connID, $charset) === 0; } + /** + * Executes a transaction control command (BEGIN, COMMIT, ROLLBACK). + * + * Captures the result resource so SQLSTATE is available via error(), + * resets $lastFailedResult to prevent stale state, and wraps any + * ErrorException into a DatabaseException for consistent error semantics. + */ + private function executeTransactionCommand(string $sql): bool + { + $this->lastFailedResult = null; + + try { + $result = pg_query($this->connID, $sql); + } catch (ErrorException $e) { + $this->lastException = new DatabaseException($e->getMessage(), 0, $e); + + return false; + } + + if ($result === false) { + // Connection-level failure: no result resource, SQLSTATE unavailable. + // error() will fall back to pg_last_error() with code '08006'. + return false; + } + + if (pg_result_status($result) === PGSQL_FATAL_ERROR) { + $this->lastFailedResult = $result; + + $sqlstate = (string) pg_result_error_field($result, PGSQL_DIAG_SQLSTATE); + $message = (string) pg_result_error($result); + $this->lastException = new DatabaseException($message, $sqlstate); + + return false; + } + + return true; + } + /** * Begin Transaction */ protected function _transBegin(): bool { - return (bool) pg_query($this->connID, 'BEGIN'); + return $this->executeTransactionCommand('BEGIN'); } /** @@ -591,7 +757,7 @@ protected function _transBegin(): bool */ protected function _transCommit(): bool { - return (bool) pg_query($this->connID, 'COMMIT'); + return $this->executeTransactionCommand('COMMIT'); } /** @@ -599,6 +765,6 @@ protected function _transCommit(): bool */ protected function _transRollback(): bool { - return (bool) pg_query($this->connID, 'ROLLBACK'); + return $this->executeTransactionCommand('ROLLBACK'); } } diff --git a/system/Database/Postgre/PreparedQuery.php b/system/Database/Postgre/PreparedQuery.php index 08900a6ddcfd..de22ca7f5e87 100644 --- a/system/Database/Postgre/PreparedQuery.php +++ b/system/Database/Postgre/PreparedQuery.php @@ -14,7 +14,6 @@ namespace CodeIgniter\Database\Postgre; use CodeIgniter\Database\BasePreparedQuery; -use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Exceptions\BadMethodCallException; use Exception; use PgSql\Connection as PgSqlConnection; @@ -70,7 +69,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery $this->errorString = pg_last_error($this->db->connID); if ($this->db->DBDebug) { - throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + throw $this->db->createDatabaseException($this->errorString, $this->errorCode); } } @@ -93,9 +92,53 @@ public function _execute(array $data): bool } } - $this->result = pg_execute($this->db->connID, $this->name, $data); + $sent = pg_send_execute($this->db->connID, $this->name, $data); - return (bool) $this->result; + if ($sent === false || $sent === 0) { + $this->errorCode = 0; + $this->errorString = pg_last_error($this->db->connID); + $this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode); + + return false; + } + + $this->result = pg_get_result($this->db->connID); + + if ($this->result === false) { + $this->errorCode = 0; + $this->errorString = pg_last_error($this->db->connID); + $this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode); + + return false; + } + + $lastResult = $this->result; + $failedResult = pg_result_status($this->result) === PGSQL_FATAL_ERROR ? $this->result : null; + + while (($next = pg_get_result($this->db->connID)) !== false) { + $lastResult = $next; + + if (! $failedResult instanceof PgSqlResult && pg_result_status($next) === PGSQL_FATAL_ERROR) { + $failedResult = $next; + } + } + + $this->result = $lastResult; + + if ($failedResult instanceof PgSqlResult) { + $sqlstate = (string) pg_result_error_field($failedResult, PGSQL_DIAG_SQLSTATE); + $this->errorCode = 0; + $this->errorString = (string) pg_result_error($failedResult); + $this->databaseException = $this->db->createDatabaseException($this->errorString, $sqlstate); + + if ($this->db->DBDebug) { + throw $this->databaseException; + } + + return false; + } + + return true; } /** diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 098b08599808..08fa8c96953e 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -20,7 +20,9 @@ use CodeIgniter\Database\Query; use CodeIgniter\Database\RawSql; use CodeIgniter\Database\ResultInterface; +use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Feature; +use TypeError; /** * Builder for SQLSRV @@ -31,6 +33,9 @@ */ class Builder extends BaseBuilder { + private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)'; + private const SHARED_LOCK_HINT = ' WITH (HOLDLOCK, ROWLOCK)'; + /** * ORDER BY random keyword * @@ -74,12 +79,30 @@ protected function _fromTables(): string $from = []; foreach ($this->QBFrom as $value) { - $from[] = str_starts_with($value, '(SELECT') ? $value : $this->getFullName($value); + if (str_starts_with($value, '(SELECT')) { + $from[] = $value; + + continue; + } + + $from[] = $this->getFullName($value) . $this->compileTableLockHint(); } return implode(', ', $from); } + /** + * Compile the SQL Server table hint for the current SELECT lock mode. + */ + private function compileTableLockHint(): string + { + return match ($this->QBSelectLock) { + self::SELECT_LOCK_FOR_UPDATE => self::LOCK_FOR_UPDATE_HINT, + self::SELECT_LOCK_SHARED => self::SHARED_LOCK_HINT, + default => '', + }; + } + /** * Generates a platform-specific truncate string from the supplied data * @@ -91,83 +114,13 @@ protected function _truncate(string $table): string return 'TRUNCATE TABLE ' . $this->getFullName($table); } - /** - * Generates the JOIN portion of the query - * - * @param RawSql|string $cond - * - * @return $this - */ - public function join(string $table, $cond, string $type = '', ?bool $escape = null) + protected function compileJoinTable(string $table, bool $escape): string { - if ($type !== '') { - $type = strtoupper(trim($type)); - - if (! in_array($type, $this->joinTypes, true)) { - $type = ''; - } else { - $type .= ' '; - } - } - - // Extract any aliases that might exist. We use this information - // in the protectIdentifiers to know whether to add a table prefix - $this->trackAliases($table); - - if (! is_bool($escape)) { - $escape = $this->db->protectIdentifiers; - } - - if (! $this->hasOperator($cond)) { - $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')'; - } elseif ($escape === false) { - $cond = ' ON ' . $cond; - } else { - // Split multiple conditions - if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE) >= 1) { - $conditions = []; - $joints = $joints[0]; - array_unshift($joints, ['', 0]); - - for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) { - $joints[$i][1] += strlen($joints[$i][0]); // offset - $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]); - $pos = $joints[$i][1] - strlen($joints[$i][0]); - $joints[$i] = $joints[$i][0]; - } - - ksort($conditions); - } else { - $conditions = [$cond]; - $joints = ['']; - } - - $cond = ' ON '; - - foreach ($conditions as $i => $condition) { - $operator = $this->getOperator($condition); - - // Workaround for BETWEEN - if ($operator === false) { - $cond .= $joints[$i] . $condition; - - continue; - } - - $cond .= $joints[$i]; - $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition; - } - } - - // Do we want to escape the table name? - if ($escape === true) { + if ($escape) { $table = $this->db->protectIdentifiers($table, true, null, false); } - // Assemble the JOIN statement - $this->QBJoin[] = $type . 'JOIN ' . $this->getFullName($table) . $cond; - - return $this; + return $this->getFullName($table); } /** @@ -234,21 +187,41 @@ protected function _update(string $table, array $values): string } /** - * Increments a numeric column by the specified value. + * Increments multiple numeric columns by the specified value(s). * - * @return bool + * @param array|list $columns A list of columns or array of column => value pairs to increment. + * @param int $value The value to increment by if $columns is a list of column names. */ - public function increment(string $column, int $value = 1) + public function incrementMany(array $columns, int $value = 1): bool { - $column = $this->db->protectIdentifiers($column); + if ($columns === []) { + throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.'); + } - if ($this->castTextToInt) { - $values = [$column => "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$column})) + {$value})"]; - } else { - $values = [$column => "{$column} + {$value}"]; + if (array_is_list($columns)) { + $columns = array_fill_keys($columns, $value); + } + + $fields = []; + + foreach ($columns as $col => $val) { + if (! is_int($val)) { + throw new TypeError(sprintf( + 'Argument #1 ($columns) must contain only int values, %s given for "%s".', + get_debug_type($val), + $col, + )); + } + + $col = $this->db->protectIdentifiers($col); + if ($this->castTextToInt) { + $fields[$col] = "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$col})) + {$val})"; + } else { + $fields[$col] = "{$col} + {$val}"; + } } - $sql = $this->_update($this->QBFrom[0], $values); + $sql = $this->_update($this->QBFrom[0], $fields); if (! $this->testMode) { $this->resetWrite(); @@ -260,21 +233,41 @@ public function increment(string $column, int $value = 1) } /** - * Decrements a numeric column by the specified value. + * Decrements multiple numeric columns by the specified value(s). * - * @return bool + * @param array|list $columns A list of columns or array of column => value pairs to decrement. + * @param int $value The value to decrement by if $columns is a list of column names. */ - public function decrement(string $column, int $value = 1) + public function decrementMany(array $columns, int $value = 1): bool { - $column = $this->db->protectIdentifiers($column); + if ($columns === []) { + throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.'); + } - if ($this->castTextToInt) { - $values = [$column => "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$column})) - {$value})"]; - } else { - $values = [$column => "{$column} - {$value}"]; + if (array_is_list($columns)) { + $columns = array_fill_keys($columns, $value); } - $sql = $this->_update($this->QBFrom[0], $values); + $fields = []; + + foreach ($columns as $col => $val) { + if (! is_int($val)) { + throw new TypeError(sprintf( + 'Argument #1 ($columns) must contain only int values, %s given for "%s".', + get_debug_type($val), + $col, + )); + } + + $col = $this->db->protectIdentifiers($col); + if ($this->castTextToInt) { + $fields[$col] = "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$col})) - {$val})"; + } else { + $fields[$col] = "{$col} - {$val}"; + } + } + + $sql = $this->_update($this->QBFrom[0], $fields); if (! $this->testMode) { $this->resetWrite(); @@ -635,9 +628,54 @@ protected function compileSelect($selectOverride = false): string $sql = $this->_limit($sql . "\n"); } + $sql .= $this->compileSelectLock(); + return $this->unionInjection($sql); } + /** + * Compile the SELECT lock clause. + */ + protected function compileSelectLock(): string + { + if ($this->QBSelectLock === null) { + return ''; + } + + if ($this->QBFrom === []) { + throw new DatabaseException(sprintf( + 'SQLSRV does not support %s() without a FROM table.', + $this->selectLockMethod(), + )); + } + + if ($this->QBUnion !== []) { + throw new DatabaseException(sprintf( + 'Query Builder does not support %s() with union() or unionAll().', + $this->selectLockMethod(), + )); + } + + foreach ($this->QBFrom as $value) { + if (str_starts_with($value, '(SELECT')) { + throw new DatabaseException(sprintf( + 'SQLSRV does not support %s() on subqueries.', + $this->selectLockMethod(), + )); + } + } + + return ''; + } + + /** + * Ensures the current driver supports explaining Query Builder selects. + */ + protected function assertExplainSupported(): never + { + throw new DatabaseException('SQLSRV does not support explain().'); + } + /** * Compiles the select statement based on the other functions called * and runs the query diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 970c582f60ed..e953245a7695 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -65,6 +65,11 @@ class Connection extends BaseConnection */ public $schema = 'dbo'; + /** + * Trust server certificate. + */ + public bool $trustServerCertificate = false; + /** * Quoted identifier flag * @@ -84,6 +89,54 @@ class Connection extends BaseConnection */ protected $_reserved_identifiers = ['*']; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + $code = (string) $code; + + if (str_contains($code, '/')) { + [$sqlstate, $vendorCode] = explode('/', $code, 2); + + if ($sqlstate === '23000' && in_array((int) $vendorCode, [2627, 2601], true)) { + return true; + } + } + + $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS); + if (! is_array($errors)) { + return false; + } + + foreach ($errors as $error) { + // SQLSTATE 23000 (integrity constraint violation) with SQL Server error + // 2627 (UNIQUE CONSTRAINT or PRIMARY KEY violation) or 2601 (UNIQUE INDEX violation). + if (($error['SQLSTATE'] ?? '') === '23000' + && in_array($error['code'] ?? 0, [2627, 2601], true)) { + return true; + } + } + + return false; + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + $vendorCode = (string) (is_string($code) && str_contains($code, '/') + ? substr($code, strrpos($code, '/') + 1) + : $code); + + if (preg_match('/^\d+$/', $vendorCode) !== 1) { + return false; + } + + return in_array((int) $vendorCode, [1205, 3960], true); + } + /** * Class constructor */ @@ -109,13 +162,14 @@ public function connect(bool $persistent = false) $charset = in_array(strtolower($this->charset), ['utf-8', 'utf8'], true) ? 'UTF-8' : SQLSRV_ENC_CHAR; $connection = [ - 'UID' => empty($this->username) ? '' : $this->username, - 'PWD' => empty($this->password) ? '' : $this->password, - 'Database' => $this->database, - 'ConnectionPooling' => $persistent ? 1 : 0, - 'CharacterSet' => $charset, - 'Encrypt' => $this->encrypt === true ? 1 : 0, - 'ReturnDatesAsStrings' => 1, + 'UID' => empty($this->username) ? '' : $this->username, + 'PWD' => empty($this->password) ? '' : $this->password, + 'Database' => $this->database, + 'ConnectionPooling' => $persistent ? 1 : 0, + 'CharacterSet' => $charset, + 'Encrypt' => $this->encrypt === true ? 1 : 0, + 'TrustServerCertificate' => $this->trustServerCertificate ? 1 : 0, + 'ReturnDatesAsStrings' => 1, ]; // If the username and password are both empty, assume this is a @@ -519,56 +573,28 @@ protected function execute(string $sql) : sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]); if ($stmt === false) { - $trace = debug_backtrace(); - $first = array_shift($trace); + $trace = debug_backtrace(); + $first = array_shift($trace); + $message = $this->getAllErrorMessages(); log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ - 'message' => $this->getAllErrorMessages(), + 'message' => $message, 'exFile' => clean_path($first['file']), 'exLine' => $first['line'], 'trace' => render_backtrace($trace), ]); + $error = $this->error(); + $exception = $this->createDatabaseException($message, $error['code']); + if ($this->DBDebug) { - throw new DatabaseException($this->getAllErrorMessages()); + throw $exception; } - } - return $stmt; - } - - /** - * Returns the last error encountered by this connection. - * - * @return array - * - * @deprecated Use `error()` instead. - */ - public function getError() - { - $error = [ - 'code' => '00000', - 'message' => '', - ]; - - $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS); - - if (! is_array($sqlsrvErrors)) { - return $error; - } - - $sqlsrvError = array_shift($sqlsrvErrors); - if (isset($sqlsrvError['SQLSTATE'])) { - $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE']; - } elseif (isset($sqlsrvError['code'])) { - $error['code'] = $sqlsrvError['code']; + $this->lastException = $exception; } - if (isset($sqlsrvError['message'])) { - $error['message'] = $sqlsrvError['message']; - } - - return $error; + return $stmt; } /** diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php index 64a4ffaf6d35..5d7e73e44f4c 100644 --- a/system/Database/SQLSRV/Forge.php +++ b/system/Database/SQLSRV/Forge.php @@ -94,15 +94,6 @@ class Forge extends BaseForge */ protected $fkAllowActions = ['CASCADE', 'SET NULL', 'NO ACTION', 'RESTRICT', 'SET DEFAULT']; - /** - * CREATE TABLE IF statement - * - * @var string - * - * @deprecated This is no longer used. - */ - protected $createTableIfStr; - /** * CREATE TABLE statement * diff --git a/system/Database/SQLSRV/PreparedQuery.php b/system/Database/SQLSRV/PreparedQuery.php index 19d2d5adfe6b..9336e231dd2c 100644 --- a/system/Database/SQLSRV/PreparedQuery.php +++ b/system/Database/SQLSRV/PreparedQuery.php @@ -65,12 +65,14 @@ public function _prepare(string $sql, array $options = []): PreparedQuery $this->statement = sqlsrv_prepare($this->db->connID, $sql, $parameters); if (! $this->statement) { + $info = $this->db->error(); + $this->databaseException = $this->db->createDatabaseException($this->db->getAllErrorMessages(), $info['code']); + if ($this->db->DBDebug) { - throw new DatabaseException($this->db->getAllErrorMessages()); + throw $this->databaseException; } - $info = $this->db->error(); - $this->errorCode = $info['code']; + $this->errorCode = is_int($info['code']) ? $info['code'] : 0; $this->errorString = $info['message']; } @@ -93,8 +95,16 @@ public function _execute(array $data): bool $result = sqlsrv_execute($this->statement); - if ($result === false && $this->db->DBDebug) { - throw new DatabaseException($this->db->getAllErrorMessages()); + if ($result === false) { + $error = $this->db->error(); + + $this->errorCode = is_int($error['code']) ? $error['code'] : 0; + $this->errorString = $this->db->getAllErrorMessages(); + $this->databaseException = $this->db->createDatabaseException($this->errorString, $error['code']); + + if ($this->db->DBDebug) { + throw $this->databaseException; + } } return $result; diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index 4f8dff97a0ea..cbdbc1f5ba50 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -55,6 +55,29 @@ class Builder extends BaseBuilder 'insert' => 'OR IGNORE', ]; + /** + * Compile the SELECT lock clause. + */ + protected function compileSelectLock(): string + { + if ($this->QBSelectLock !== null) { + throw new DatabaseException(sprintf( + 'SQLite3 does not support %s().', + $this->selectLockMethod(), + )); + } + + return ''; + } + + /** + * Compiles an execution-plan query for the current SELECT query. + */ + protected function compileExplain(string $sql): string + { + return 'EXPLAIN QUERY PLAN ' . $sql; + } + /** * Replace statement * diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 268ceaa7bea2..e312fee681f0 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -66,6 +66,26 @@ class Connection extends BaseConnection */ protected ?int $synchronous = null; + /** + * Checks whether the native database error represents a unique constraint violation. + */ + protected function isUniqueConstraintViolation(int|string $code, string $message): bool + { + // SQLite3 reports unique violations in two formats depending on version: + // Modern: "UNIQUE constraint failed: table.column" + // Legacy: "column X is not unique" + return str_contains($message, 'UNIQUE constraint failed') + || str_contains($message, 'is not unique'); + } + + /** + * Checks whether the native database code represents a retryable transaction failure. + */ + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 5; + } + /** * @return void */ @@ -170,9 +190,14 @@ protected function execute(string $sql) 'trace' => render_backtrace($e->getTrace()), ]); + $error = $this->error(); + $exception = $this->createDatabaseException($e->getMessage(), $error['code'], $e); + if ($this->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $exception; } + + $this->lastException = $exception; } return false; diff --git a/system/Database/SQLite3/PreparedQuery.php b/system/Database/SQLite3/PreparedQuery.php index afd2669c6436..d52bae99f06a 100644 --- a/system/Database/SQLite3/PreparedQuery.php +++ b/system/Database/SQLite3/PreparedQuery.php @@ -14,7 +14,6 @@ namespace CodeIgniter\Database\SQLite3; use CodeIgniter\Database\BasePreparedQuery; -use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Exceptions\BadMethodCallException; use Exception; use SQLite3; @@ -52,7 +51,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery $this->errorString = $this->db->connID->lastErrorMsg(); if ($this->db->DBDebug) { - throw new DatabaseException($this->errorString . ' code: ' . $this->errorCode); + throw $this->db->createDatabaseException($this->errorString, $this->errorCode); } } @@ -88,13 +87,27 @@ public function _execute(array $data): bool try { $this->result = $this->statement->execute(); } catch (Exception $e) { + $error = $this->db->error(); + $this->errorCode = $error['code']; + $this->errorString = $e->getMessage(); + $this->databaseException = $this->db->createDatabaseException($this->errorString, $this->errorCode, $e); + if ($this->db->DBDebug) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + throw $this->databaseException; } return false; } + if ($this->result === false) { + $this->errorCode = $this->db->connID->lastErrorCode(); + $this->errorString = $this->db->connID->lastErrorMsg(); + + if ($this->db->DBDebug) { + throw $this->db->createDatabaseException($this->errorString, $this->errorCode); + } + } + return $this->result !== false; } diff --git a/system/Database/Seeder.php b/system/Database/Seeder.php index edbf3251cbf3..df7a6400759c 100644 --- a/system/Database/Seeder.php +++ b/system/Database/Seeder.php @@ -16,8 +16,6 @@ use CodeIgniter\CLI\CLI; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database; -use Faker\Factory; -use Faker\Generator; /** * Class Seeder @@ -66,13 +64,6 @@ class Seeder */ protected $silent = false; - /** - * Faker Generator instance. - * - * @deprecated - */ - private static ?Generator $faker = null; - /** * Seeder constructor. */ @@ -104,20 +95,6 @@ public function __construct(Database $config, ?BaseConnection $db = null) } } - /** - * Gets the Faker Generator instance. - * - * @deprecated - */ - public static function faker(): ?Generator - { - if (! self::$faker instanceof Generator && class_exists(Factory::class)) { - self::$faker = Factory::create(); - } - - return self::$faker; - } - /** * Loads the specified seeder and runs it. * diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php index b9180a98bd59..a12d2ff6c0f9 100644 --- a/system/Debug/BaseExceptionHandler.php +++ b/system/Debug/BaseExceptionHandler.php @@ -13,10 +13,6 @@ namespace CodeIgniter\Debug; -use CodeIgniter\HTTP\CLIRequest; -use CodeIgniter\HTTP\IncomingRequest; -use CodeIgniter\HTTP\RequestInterface; -use CodeIgniter\HTTP\ResponseInterface; use Config\Exceptions as ExceptionsConfig; use Throwable; @@ -53,21 +49,6 @@ public function __construct(ExceptionsConfig $config) } } - /** - * The main entry point into the handler. - * - * @param CLIRequest|IncomingRequest $request - * - * @return void - */ - abstract public function handle( - Throwable $exception, - RequestInterface $request, - ResponseInterface $response, - int $statusCode, - int $exitCode, - ); - /** * Gathers the variables that will be made available to the view. */ @@ -76,7 +57,7 @@ protected function collectVars(Throwable $exception, int $statusCode): array // Get the first exception. $firstException = $exception; - while ($prevException = $firstException->getPrevious()) { + while (($prevException = $firstException->getPrevious()) instanceof Throwable) { $firstException = $prevException; } @@ -103,24 +84,22 @@ protected function collectVars(Throwable $exception, int $statusCode): array protected function maskSensitiveData(array $trace, array $keysToMask, string $path = ''): array { foreach ($trace as $i => $line) { - $trace[$i]['args'] = $this->maskData($line['args'], $keysToMask); + $trace[$i]['args'] = $this->maskData($line['args'], $keysToMask, $path); } return $trace; } /** - * @param array|object $args - * - * @return array|object + * @param array $keysToMask */ - private function maskData($args, array $keysToMask, string $path = '') + private function maskData(mixed $args, array $keysToMask, string $path = ''): mixed { - foreach ($keysToMask as $keyToMask) { - $explode = explode('/', $keyToMask); + foreach ($keysToMask as $key) { + $explode = explode('/', $key); $index = end($explode); - if (str_starts_with(strrev($path . '/' . $index), strrev($keyToMask))) { + if (str_starts_with(strrev($path . '/' . $index), strrev($key))) { if (is_array($args) && array_key_exists($index, $args)) { $args[$index] = '******************'; } elseif ( diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php index 805b5691c079..61ba7cc82b2a 100644 --- a/system/Debug/ExceptionHandler.php +++ b/system/Debug/ExceptionHandler.php @@ -94,10 +94,8 @@ public function handle( $this->respond($data, $statusCode)->send(); - if (ENVIRONMENT !== 'testing') { - // @codeCoverageIgnoreStart - exit($exitCode); - // @codeCoverageIgnoreEnd + if (! service('environment')->isTesting()) { + exit($exitCode); // @codeCoverageIgnore } return; @@ -125,10 +123,8 @@ public function handle( // Displays the HTML or CLI error code. $this->render($exception, $statusCode, $viewFile); - if (ENVIRONMENT !== 'testing') { - // @codeCoverageIgnoreStart - exit($exitCode); - // @codeCoverageIgnoreEnd + if (! service('environment')->isTesting()) { + exit($exitCode); // @codeCoverageIgnore } } diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 3c8b1f006ea7..d9ae9f8cf381 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -16,12 +16,10 @@ use CodeIgniter\API\ResponseTrait; use CodeIgniter\Exceptions\HasExitCodeInterface; use CodeIgniter\Exceptions\HTTPExceptionInterface; -use CodeIgniter\Exceptions\PageNotFoundException; -use CodeIgniter\HTTP\Exceptions\HTTPException; -use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\ResponseInterface; use Config\Exceptions as ExceptionsConfig; -use Config\Paths; use ErrorException; use Psr\Log\LogLevel; use Throwable; @@ -35,25 +33,6 @@ class Exceptions { use ResponseTrait; - /** - * Nesting level of the output buffering mechanism - * - * @var int - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. - */ - public $ob_level; - - /** - * The path to the directory containing the - * cli and html error view directories. - * - * @var string - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. - */ - protected $viewPath; - /** * Config for debug exceptions. * @@ -64,7 +43,7 @@ class Exceptions /** * The request. * - * @var RequestInterface|null + * @var CLIRequest|IncomingRequest */ protected $request; @@ -79,10 +58,6 @@ class Exceptions public function __construct(ExceptionsConfig $config) { - // For backward compatibility - $this->ob_level = ob_get_level(); - $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; - $this->config = $config; } @@ -90,21 +65,17 @@ public function __construct(ExceptionsConfig $config) * Responsible for registering the error, exception and shutdown * handling of our application. * - * @codeCoverageIgnore - * * @return void */ public function initialize() { set_exception_handler($this->exceptionHandler(...)); set_error_handler($this->errorHandler(...)); - register_shutdown_function([$this, 'shutdownHandler']); + register_shutdown_function($this->shutdownHandler(...)); } /** - * Catches any uncaught errors and exceptions, including most Fatal errors - * (Yay PHP7!). Will log the error, display it if display_errors is on, - * and fire an event that allows custom actions to be taken at this point. + * The callback to be registered to `set_exception_handler()`. * * @return void */ @@ -116,25 +87,26 @@ public function exceptionHandler(Throwable $exception) $this->request = service('request'); - if ($this->config->log === true && ! in_array($statusCode, $this->config->ignoreCodes, true)) { - $uri = $this->request->getPath() === '' ? '/' : $this->request->getPath(); - $routeInfo = '[Method: ' . $this->request->getMethod() . ', Route: ' . $uri . ']'; + if ($this->config->log && ! in_array($statusCode, $this->config->ignoreCodes, true)) { + $uri = $this->request->getPath() === '' ? '/' : $this->request->getPath(); - log_message('critical', $exception::class . ": {message}\n{routeInfo}\nin {exFile} on line {exLine}.\n{trace}", [ + log_message('critical', "{exClass}: {message}\n{routeInfo}\nin {exFile} on line {exLine}.\n{trace}", [ + 'exClass' => $exception::class, 'message' => $exception->getMessage(), - 'routeInfo' => $routeInfo, + 'routeInfo' => sprintf('[Method: %s, Route: %s]', $this->request->getMethod(), $uri), 'exFile' => clean_path($exception->getFile()), // {file} refers to THIS file 'exLine' => $exception->getLine(), // {line} refers to THIS line 'trace' => render_backtrace($exception->getTrace()), ]); // Get the first exception. - $last = $exception; + $firstException = $exception; - while ($prevException = $last->getPrevious()) { - $last = $prevException; + while (($prevException = $firstException->getPrevious()) instanceof Throwable) { + $firstException = $prevException; - log_message('critical', '[Caused by] ' . $prevException::class . ": {message}\nin {exFile} on line {exLine}.\n{trace}", [ + log_message('critical', "[Caused by] {exClass}: {message}\nin {exFile} on line {exLine}.\n{trace}", [ + 'exClass' => $prevException::class, 'message' => $prevException->getMessage(), 'exFile' => clean_path($prevException->getFile()), // {file} refers to THIS file 'exLine' => $prevException->getLine(), // {line} refers to THIS line @@ -145,44 +117,8 @@ public function exceptionHandler(Throwable $exception) $this->response = service('response'); - if (method_exists($this->config, 'handler')) { - // Use new ExceptionHandler - $handler = $this->config->handler($statusCode, $exception); - $handler->handle( - $exception, - $this->request, - $this->response, - $statusCode, - $exitCode, - ); - - return; - } - - // For backward compatibility - if (! is_cli()) { - try { - $this->response->setStatusCode($statusCode); - } catch (HTTPException) { - // Workaround for invalid HTTP status code. - $statusCode = 500; - $this->response->setStatusCode($statusCode); - } - - if (! headers_sent()) { - header(sprintf('HTTP/%s %s %s', $this->request->getProtocolVersion(), $this->response->getStatusCode(), $this->response->getReasonPhrase()), true, $statusCode); - } - - if (! str_contains($this->request->getHeaderLine('accept'), 'text/html')) { - $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send(); - - exit($exitCode); - } - } - - $this->render($exception, $statusCode); - - exit($exitCode); + $handler = $this->config->handler($statusCode, $exception); + $handler->handle($exception, $this->request, $this->response, $statusCode, $exitCode); } /** @@ -191,8 +127,6 @@ public function exceptionHandler(Throwable $exception) * @return bool * * @throws ErrorException - * - * @codeCoverageIgnore */ public function errorHandler(int $severity, string $message, ?string $file = null, ?int $line = null) { @@ -215,38 +149,10 @@ public function errorHandler(int $severity, string $message, ?string $file = nul return false; // return false to propagate the error to PHP standard error handler } - /** - * Handles session.sid_length and session.sid_bits_per_character deprecations - * in PHP 8.4. - */ - private function isSessionSidDeprecationError(string $message, ?string $file = null, ?int $line = null): bool - { - if ( - PHP_VERSION_ID >= 80400 - && str_contains($message, 'session.sid_') - ) { - log_message( - LogLevel::WARNING, - '[DEPRECATED] {message} in {errFile} on line {errLine}.', - [ - 'message' => $message, - 'errFile' => clean_path($file ?? ''), - 'errLine' => $line ?? 0, - ], - ); - - return true; - } - - return false; - } - /** * Checks to see if any errors have happened during shutdown that * need to be caught and handle them. * - * @codeCoverageIgnore - * * @return void */ public function shutdownHandler() @@ -272,171 +178,25 @@ public function shutdownHandler() } /** - * Determines the view to display based on the exception thrown, - * whether an HTTP or CLI request, etc. - * - * @return string The path and filename of the view file to use - * - * @deprecated 4.4.0 No longer used. Moved to ExceptionHandler. - */ - protected function determineView(Throwable $exception, string $templatePath): string - { - // Production environments should have a custom exception file. - $view = 'production.php'; - $templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR; - - if ( - in_array( - strtolower(ini_get('display_errors')), - ['1', 'true', 'on', 'yes'], - true, - ) - ) { - $view = 'error_exception.php'; - } - - // 404 Errors - if ($exception instanceof PageNotFoundException) { - return 'error_404.php'; - } - - // Allow for custom views based upon the status code - if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) { - return 'error_' . $exception->getCode() . '.php'; - } - - return $view; - } - - /** - * Given an exception and status code will display the error to the client. - * - * @return void - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. - */ - protected function render(Throwable $exception, int $statusCode) - { - // Determine possible directories of error views - $path = $this->viewPath; - $altPath = rtrim((new Paths())->viewDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR; - - $path .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR; - $altPath .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR; - - // Determine the views - $view = $this->determineView($exception, $path); - $altView = $this->determineView($exception, $altPath); - - // Check if the view exists - if (is_file($path . $view)) { - $viewFile = $path . $view; - } elseif (is_file($altPath . $altView)) { - $viewFile = $altPath . $altView; - } - - if (! isset($viewFile)) { - echo 'The error view files were not found. Cannot render exception trace.'; - - exit(1); - } - - echo (function () use ($exception, $statusCode, $viewFile): string { - $vars = $this->collectVars($exception, $statusCode); - extract($vars, EXTR_SKIP); - - ob_start(); - include $viewFile; - - return ob_get_clean(); - })(); - } - - /** - * Gathers the variables that will be made available to the view. - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. + * Handles session.sid_length and session.sid_bits_per_character deprecations in PHP 8.4. */ - protected function collectVars(Throwable $exception, int $statusCode): array - { - // Get the first exception. - $firstException = $exception; - - while ($prevException = $firstException->getPrevious()) { - $firstException = $prevException; - } - - $trace = $firstException->getTrace(); - - if ($this->config->sensitiveDataInTrace !== []) { - $trace = $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace); - } - - return [ - 'title' => $exception::class, - 'type' => $exception::class, - 'code' => $statusCode, - 'message' => $exception->getMessage(), - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $trace, - ]; - } - - /** - * Mask sensitive data in the trace. - * - * @param array $trace - * - * @return array - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. - */ - protected function maskSensitiveData($trace, array $keysToMask, string $path = '') - { - foreach ($trace as $i => $line) { - $trace[$i]['args'] = $this->maskData($line['args'], $keysToMask); - } - - return $trace; - } - - /** - * @param array|object $args - * - * @return array|object - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. - */ - private function maskData($args, array $keysToMask, string $path = '') + private function isSessionSidDeprecationError(string $message, ?string $file = null, ?int $line = null): bool { - foreach ($keysToMask as $keyToMask) { - $explode = explode('/', $keyToMask); - $index = end($explode); - - if (str_starts_with(strrev($path . '/' . $index), strrev($keyToMask))) { - if (is_array($args) && array_key_exists($index, $args)) { - $args[$index] = '******************'; - } elseif ( - is_object($args) && property_exists($args, $index) - && isset($args->{$index}) && is_scalar($args->{$index}) - ) { - $args->{$index} = '******************'; - } - } - } + if (PHP_VERSION_ID >= 80400 && str_contains($message, 'session.sid_')) { + log_message( + LogLevel::WARNING, + '[DEPRECATED] {message} in {errFile} on line {errLine}.', + [ + 'message' => $message, + 'errFile' => clean_path($file ?? ''), + 'errLine' => $line ?? 0, + ], + ); - if (is_array($args)) { - foreach ($args as $pathKey => $subarray) { - $args[$pathKey] = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); - } - } elseif (is_object($args)) { - foreach ($args as $pathKey => $subarray) { - $args->{$pathKey} = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); - } + return true; } - return $args; + return false; } /** @@ -465,10 +225,7 @@ private function isDeprecationError(int $error): bool return ($error & $deprecations) !== 0; } - /** - * @return true - */ - private function handleDeprecationError(string $message, ?string $file = null, ?int $line = null): bool + private function handleDeprecationError(string $message, ?string $file = null, ?int $line = null): true { // Remove the trace of the error handler. $trace = array_slice(debug_backtrace(), 2); @@ -486,117 +243,4 @@ private function handleDeprecationError(string $message, ?string $file = null, ? return true; } - - // -------------------------------------------------------------------- - // Display Methods - // -------------------------------------------------------------------- - - /** - * This makes nicer looking paths for the error output. - * - * @deprecated Use dedicated `clean_path()` function. - */ - public static function cleanPath(string $file): string - { - return match (true) { - str_starts_with($file, APPPATH) => 'APPPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(APPPATH)), - str_starts_with($file, SYSTEMPATH) => 'SYSTEMPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(SYSTEMPATH)), - str_starts_with($file, FCPATH) => 'FCPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(FCPATH)), - defined('VENDORPATH') && str_starts_with($file, VENDORPATH) => 'VENDORPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(VENDORPATH)), - default => $file, - }; - } - - /** - * Describes memory usage in real-world units. Intended for use - * with memory_get_usage, etc. - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. - */ - public static function describeMemory(int $bytes): string - { - if ($bytes < 1024) { - return $bytes . 'B'; - } - - if ($bytes < 1_048_576) { - return round($bytes / 1024, 2) . 'KB'; - } - - return round($bytes / 1_048_576, 2) . 'MB'; - } - - /** - * Creates a syntax-highlighted version of a PHP file. - * - * @return bool|string - * - * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. - */ - public static function highlightFile(string $file, int $lineNumber, int $lines = 15) - { - if ($file === '' || ! is_readable($file)) { - return false; - } - - // Set our highlight colors: - if (function_exists('ini_set')) { - ini_set('highlight.comment', '#767a7e; font-style: italic'); - ini_set('highlight.default', '#c7c7c7'); - ini_set('highlight.html', '#06B'); - ini_set('highlight.keyword', '#f1ce61;'); - ini_set('highlight.string', '#869d6a'); - } - - try { - $source = file_get_contents($file); - } catch (Throwable) { - return false; - } - - $source = str_replace(["\r\n", "\r"], "\n", $source); - $source = explode("\n", highlight_string($source, true)); - $source = str_replace('
', "\n", $source[1]); - $source = explode("\n", str_replace("\r\n", "\n", $source)); - - // Get just the part to show - $start = max($lineNumber - (int) round($lines / 2), 0); - - // Get just the lines we need to display, while keeping line numbers... - $source = array_splice($source, $start, $lines, true); - - // Used to format the line number in the source - $format = '% ' . strlen((string) ($start + $lines)) . 'd'; - - $out = ''; - // Because the highlighting may have an uneven number - // of open and close span tags on one line, we need - // to ensure we can close them all to get the lines - // showing correctly. - $spans = 1; - - foreach ($source as $n => $row) { - $spans += substr_count($row, ']+>#', $row, $tags); - - $out .= sprintf( - "{$format} %s\n%s", - $n + $start + 1, - strip_tags($row), - implode('', $tags[0]), - ); - } else { - $out .= sprintf('' . $format . ' %s', $n + $start + 1, $row) . "\n"; - } - } - - if ($spans > 0) { - $out .= str_repeat('', $spans); - } - - return '
' . $out . '
'; - } } diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index f5885aa0555c..c75f26ec897d 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -19,9 +19,9 @@ use CodeIgniter\Debug\Toolbar\Collectors\History; use CodeIgniter\Format\JSONFormatter; use CodeIgniter\Format\XMLFormatter; -use CodeIgniter\HTTP\DownloadResponse; use CodeIgniter\HTTP\Header; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\NonBufferedResponseInterface; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\I18n\Time; @@ -380,8 +380,8 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r /** @var ResponseInterface $response */ $response ??= service('response'); - // Disable the toolbar for downloads - if ($response instanceof DownloadResponse) { + // Disable the toolbar for non-buffered responses (downloads, SSE) + if ($response instanceof NonBufferedResponseInterface) { return; } @@ -457,7 +457,7 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r */ public function respond(): void { - if (ENVIRONMENT === 'testing') { + if (service('environment')->isTesting()) { return; } diff --git a/system/Debug/Toolbar/Collectors/BaseCollector.php b/system/Debug/Toolbar/Collectors/BaseCollector.php index 81cc631f98e7..83ceec226268 100644 --- a/system/Debug/Toolbar/Collectors/BaseCollector.php +++ b/system/Debug/Toolbar/Collectors/BaseCollector.php @@ -175,16 +175,6 @@ public function display() return []; } - /** - * This makes nicer looking paths for the error output. - * - * @deprecated Use the dedicated `clean_path()` function. - */ - public function cleanPath(string $file): string - { - return clean_path($file); - } - /** * Gets the "badge" value for the button. * diff --git a/system/Debug/Toolbar/Collectors/Config.php b/system/Debug/Toolbar/Collectors/Config.php index 80673f979f6c..069091847433 100644 --- a/system/Debug/Toolbar/Collectors/Config.php +++ b/system/Debug/Toolbar/Collectors/Config.php @@ -32,7 +32,7 @@ public static function display(): array 'ciVersion' => CodeIgniter::CI_VERSION, 'phpVersion' => PHP_VERSION, 'phpSAPI' => PHP_SAPI, - 'environment' => ENVIRONMENT, + 'environment' => service('environment')->get(), 'baseURL' => $config->baseURL, 'timezone' => app_timezone(), 'locale' => service('request')->getLocale(), diff --git a/system/Entity/Cast/FloatCast.php b/system/Entity/Cast/FloatCast.php index 1a767c0953f0..20310770177d 100644 --- a/system/Entity/Cast/FloatCast.php +++ b/system/Entity/Cast/FloatCast.php @@ -13,10 +13,26 @@ namespace CodeIgniter\Entity\Cast; +use CodeIgniter\Entity\Exceptions\CastException; + class FloatCast extends BaseCast { public static function get($value, array $params = []): float { - return (float) $value; + $precision = isset($params[0]) ? (int) $params[0] : null; + + if ($precision === null) { + return (float) $value; + } + + $mode = match (strtolower($params[1] ?? 'up')) { + 'up' => PHP_ROUND_HALF_UP, + 'down' => PHP_ROUND_HALF_DOWN, + 'even' => PHP_ROUND_HALF_EVEN, + 'odd' => PHP_ROUND_HALF_ODD, + default => throw CastException::forInvalidFloatRoundingMode($params[1]), + }; + + return round((float) $value, $precision, $mode); } } diff --git a/system/Entity/Exceptions/CastException.php b/system/Entity/Exceptions/CastException.php index 033f1ced478e..252eb0c42df2 100644 --- a/system/Entity/Exceptions/CastException.php +++ b/system/Entity/Exceptions/CastException.php @@ -122,4 +122,12 @@ public static function forInvalidEnumType(string $expectedClass, string $actualC { return new static(lang('Cast.enumInvalidType', [$actualClass, $expectedClass])); } + + /** + * Thrown when an invalid rounding mode is provided for float casting. + */ + public static function forInvalidFloatRoundingMode(string $mode): static + { + return new static(lang('Cast.invalidFloatRoundingMode', [$mode])); + } } diff --git a/system/EnvironmentDetector.php b/system/EnvironmentDetector.php new file mode 100644 index 000000000000..fdccfa9b763a --- /dev/null +++ b/system/EnvironmentDetector.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Provides a simple way to determine the current environment of the application. + * + * Primarily intended as a mockable seam for testing environment-specific code + * paths that resolve this class via the `environment` service. + * + * It does not redefine the `ENVIRONMENT` constant. It affects only code paths + * that resolve and use this class, while code that still reads `ENVIRONMENT` + * directly keeps its current behavior. + * + * For custom environment names beyond the built-in production/development/testing, + * use {@see self::is()}. + * + * @see \CodeIgniter\EnvironmentDetectorTest + */ +final readonly class EnvironmentDetector +{ + private string $environment; + + /** + * @param non-empty-string|null $environment The environment to use, or null to + * fall back to the `ENVIRONMENT` constant. + */ + public function __construct(?string $environment = null) + { + $environment = $environment !== null ? trim($environment) : ENVIRONMENT; + + if ($environment === '') { + throw new InvalidArgumentException('Environment cannot be an empty string.'); + } + + $this->environment = $environment; + } + + public function get(): string + { + return $this->environment; + } + + /** + * Checks if the current environment matches any of the given environments. + * + * @param string ...$environments One or more environment names to check against. + */ + public function is(string ...$environments): bool + { + return in_array($this->environment, $environments, true); + } + + public function isProduction(): bool + { + return $this->is('production'); + } + + public function isDevelopment(): bool + { + return $this->is('development'); + } + + public function isTesting(): bool + { + return $this->is('testing'); + } +} diff --git a/system/Exceptions/DownloadException.php b/system/Exceptions/DownloadException.php index c3bde8ed2556..35908620a8fa 100644 --- a/system/Exceptions/DownloadException.php +++ b/system/Exceptions/DownloadException.php @@ -44,16 +44,6 @@ public static function forNotFoundDownloadSource() return new static(lang('HTTP.notFoundDownloadSource')); } - /** - * @deprecated Since v4.5.6 - * - * @return static - */ - public static function forCannotSetCache() - { - return new static(lang('HTTP.cannotSetCache')); - } - /** * @return static */ diff --git a/system/Exceptions/FrameworkException.php b/system/Exceptions/FrameworkException.php index 4650c6c3541f..dec877e8dea0 100644 --- a/system/Exceptions/FrameworkException.php +++ b/system/Exceptions/FrameworkException.php @@ -55,27 +55,6 @@ public static function forCopyError(string $path) return new static(lang('Core.copyError', [$path])); } - /** - * @return static - * - * @deprecated 4.5.0 No longer used. - */ - public static function forMissingExtension(string $extension) - { - if (str_contains($extension, 'intl')) { - // @codeCoverageIgnoreStart - $message = sprintf( - 'The framework needs the following extension(s) installed and loaded: %s.', - $extension, - ); - // @codeCoverageIgnoreEnd - } else { - $message = lang('Core.missingExtension', [$extension]); - } - - return new static($message); - } - /** * @return static */ diff --git a/system/Filters/CSRF.php b/system/Filters/CSRF.php index ea9a9939f2de..b2bf1d53b25b 100644 --- a/system/Filters/CSRF.php +++ b/system/Filters/CSRF.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Filters; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; @@ -52,12 +53,19 @@ public function before(RequestInterface $request, $arguments = null) $security->verify($request); } catch (SecurityException $e) { if ($security->shouldRedirect() && ! $request->isAJAX()) { - return redirect()->back()->with('error', $e->getMessage()); + $response = redirect()->back()->with('error', $e->getMessage()); + $this->addFetchMetadataVaryHeader($request, $response, $security); + + return $response; } + $this->addFetchMetadataVaryHeader($request, service('response'), $security); + throw $e; } + $this->addFetchMetadataVaryHeader($request, service('response'), $security); + return null; } @@ -70,4 +78,13 @@ public function after(RequestInterface $request, ResponseInterface $response, $a { return null; } + + private function addFetchMetadataVaryHeader(IncomingRequest $request, ResponseInterface $response, Security $security): void + { + $isUnsafeMethod = in_array($request->getMethod(), [Method::POST, Method::PUT, Method::DELETE, Method::PATCH], true); + + if ($security->shouldUseFetchMetadata() && $isUnsafeMethod) { + $response->appendHeader('Vary', 'Sec-Fetch-Site'); + } + } } diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index bf5419eab560..d5c66b10f33f 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -22,8 +22,6 @@ use Config\Modules; /** - * Filters - * * @see \CodeIgniter\Filters\FiltersTest */ class Filters @@ -125,26 +123,6 @@ class Filters protected array $filterClassInstances = []; /** - * Any arguments to be passed to filters. - * - * @var array|null> [name => params] - * - * @deprecated 4.6.0 No longer used. - */ - protected $arguments = []; - - /** - * Any arguments to be passed to filtersClass. - * - * @var array|null> [classname => arguments] - * - * @deprecated 4.6.0 No longer used. - */ - protected $argumentsClass = []; - - /** - * Constructor. - * * @param FiltersConfig $config */ public function __construct($config, RequestInterface $request, ResponseInterface $response, ?Modules $modules = null) @@ -501,8 +479,6 @@ public function reset(): self { $this->initialized = false; - $this->arguments = $this->argumentsClass = []; - $this->filters = $this->filtersClass = [ 'before' => [], 'after' => [], @@ -644,18 +620,6 @@ public function enableFilters(array $filters, string $when = 'before') return $this; } - /** - * Returns the arguments for a specified key, or all. - * - * @return array|string - * - * @deprecated 4.6.0 Already does not work. - */ - public function getArguments(?string $key = null) - { - return ((string) $key === '') ? $this->arguments : $this->arguments[$key]; - } - // -------------------------------------------------------------------- // Processors // -------------------------------------------------------------------- @@ -724,27 +688,9 @@ protected function processMethods() { $method = $this->request->getMethod(); - $found = false; - if (array_key_exists($method, $this->config->methods)) { - $found = true; - } - // Checks lowercase HTTP method for backward compatibility. - // @deprecated 4.5.0 - // @TODO remove this in the future. - elseif (array_key_exists(strtolower($method), $this->config->methods)) { - @trigger_error( - 'Setting lowercase HTTP method key "' . strtolower($method) . '" is deprecated.' - . ' Use uppercase HTTP method like "' . strtoupper($method) . '".', - E_USER_DEPRECATED, - ); - - $found = true; - $method = strtolower($method); - } - - if ($found) { $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false; // @phpstan-ignore nullCoalesce.property + if ($oldFilterOrder) { $this->filters['before'] = array_merge($this->filters['before'], $this->config->methods[$method]); } else { diff --git a/system/Filters/PageCache.php b/system/Filters/PageCache.php index ff4bd097f84c..a4858c400c71 100644 --- a/system/Filters/PageCache.php +++ b/system/Filters/PageCache.php @@ -15,8 +15,8 @@ use CodeIgniter\Cache\ResponseCache; use CodeIgniter\HTTP\CLIRequest; -use CodeIgniter\HTTP\DownloadResponse; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\NonBufferedResponseInterface; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; @@ -68,7 +68,7 @@ public function after(RequestInterface $request, ResponseInterface $response, $a assert($request instanceof CLIRequest || $request instanceof IncomingRequest); if ( - ! $response instanceof DownloadResponse + ! $response instanceof NonBufferedResponseInterface && ! $response instanceof RedirectResponse && ($this->cacheStatusCodes === [] || in_array($response->getStatusCode(), $this->cacheStatusCodes, true)) ) { diff --git a/system/Filters/RequestId.php b/system/Filters/RequestId.php new file mode 100644 index 000000000000..f2db86376525 --- /dev/null +++ b/system/Filters/RequestId.php @@ -0,0 +1,66 @@ + + * + * 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\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; + +class RequestId implements FilterInterface +{ + /** + * Generates a unique request ID for each incoming request and adds it to the request context. + * If the incoming request already has X-Request-ID header, then that header is used instead. + * + * {@inheritDoc} + */ + public function before(RequestInterface $request, $arguments = null): ?ResponseInterface + { + $requestId = trim($request->getHeaderLine('X-Request-ID')); + + if (! $this->isValidRequestId($requestId)) { + $requestId = bin2hex(random_bytes(16)); + } + + context()->set('request_id', $requestId); + + $request->setHeader('X-Request-ID', $requestId); + + return null; + } + + /** + * {@inheritDoc} + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): ?ResponseInterface + { + if (context()->has('request_id')) { + $response->setHeader('X-Request-ID', context()->get('request_id')); + } + + return null; + } + + private function isValidRequestId(string $requestId): bool + { + if ($requestId === '') { + return false; + } + + if (strlen($requestId) > 64) { + return false; + } + + return preg_match('/^[A-Za-z0-9._:-]+$/', $requestId) === 1; + } +} diff --git a/system/Format/JSONFormatter.php b/system/Format/JSONFormatter.php index ac237fcc7af3..69dac455b977 100644 --- a/system/Format/JSONFormatter.php +++ b/system/Format/JSONFormatter.php @@ -37,7 +37,7 @@ public function format($data) $options = $config->formatterOptions['application/json'] ?? JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; $options |= JSON_PARTIAL_OUTPUT_ON_ERROR; - if (ENVIRONMENT !== 'production') { + if (! service('environment')->isProduction()) { $options |= JSON_PRETTY_PRINT; } diff --git a/system/HTTP/CLIRequest.php b/system/HTTP/CLIRequest.php index 453272a4f59a..f4c31d03b914 100644 --- a/system/HTTP/CLIRequest.php +++ b/system/HTTP/CLIRequest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\CLI\CommandLineParser; use CodeIgniter\Exceptions\RuntimeException; use Config\App; use Locale; @@ -35,21 +36,21 @@ class CLIRequest extends Request /** * Stores the segments of our cli "URI" command. * - * @var array + * @var list */ protected $segments = []; /** * Command line options and their values. * - * @var array + * @var array|string|null> */ protected $options = []; /** * Command line arguments (segments and options). * - * @var array + * @var array|string|null> */ protected $args = []; @@ -74,7 +75,11 @@ public function __construct(App $config) // Don't terminate the script when the cli's tty goes away ignore_user_abort(true); - $this->parseCommand(); + $parser = new CommandLineParser($this->getServer('argv') ?? []); + + $this->segments = $parser->getArguments(); + $this->options = $parser->getOptions(); + $this->args = $parser->getTokens(); // Set SiteURI for this request $this->uri = new SiteURI($config, $this->getPath()); @@ -101,6 +106,8 @@ public function getPath(): string /** * Returns an associative array of all CLI options found, with * their values. + * + * @return array|string|null> */ public function getOptions(): array { @@ -109,6 +116,8 @@ public function getOptions(): array /** * Returns an array of all CLI arguments (segments and options). + * + * @return array|string|null> */ public function getArgs(): array { @@ -117,6 +126,8 @@ public function getArgs(): array /** * Returns the path segments. + * + * @return list */ public function getSegments(): array { @@ -126,9 +137,27 @@ public function getSegments(): array /** * Returns the value for a single CLI option that was passed in. * + * If an option was passed in multiple times, this will return the last value passed in for that option. + * * @return string|null */ public function getOption(string $key) + { + $value = $this->options[$key] ?? null; + + if (! is_array($value)) { + return $value; + } + + return $value[count($value) - 1]; + } + + /** + * Returns the value for a single CLI option that was passed in. + * + * @return list|string|null + */ + public function getRawOption(string $key): array|string|null { return $this->options[$key] ?? null; } @@ -151,27 +180,31 @@ public function getOptionString(bool $useLongOpts = false): string return ''; } - $out = ''; + $out = []; - foreach ($this->options as $name => $value) { - if ($useLongOpts && mb_strlen($name) > 1) { - $out .= "--{$name} "; + $valueCallback = static function (?string $value, string $name) use (&$out): void { + if ($value === null) { + $out[] = $name; + } elseif (str_contains($value, ' ')) { + $out[] = sprintf('%s "%s"', $name, $value); } else { - $out .= "-{$name} "; + $out[] = sprintf('%s %s', $name, $value); } + }; - if ($value === null) { - continue; - } + foreach ($this->options as $name => $value) { + $name = $useLongOpts && mb_strlen($name) > 1 ? "--{$name}" : "-{$name}"; - if (str_contains($value, ' ')) { - $out .= '"' . $value . '" '; + if (is_array($value)) { + foreach ($value as $val) { + $valueCallback($val, $name); + } } else { - $out .= "{$value} "; + $valueCallback($value, $name); } } - return trim($out); + return trim(implode(' ', $out)); } /** @@ -181,10 +214,14 @@ public function getOptionString(bool $useLongOpts = false): string * NOTE: I tried to use getopt but had it fail occasionally to find * any options, where argv has always had our back. * + * @deprecated 4.8.0 No longer used. + * * @return void */ protected function parseCommand() { + @trigger_error(sprintf('The %s() method is deprecated and no longer used.', __METHOD__), E_USER_DEPRECATED); + $args = $this->getServer('argv'); array_shift($args); // Scrap index.php diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index ac05f9ae903b..3c199fb9a309 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -82,6 +82,27 @@ class CURLRequest extends OutgoingRequest ], ]; + /** + * Default values for when 'retry' is enabled. + * + * @var array> + */ + protected array $retryDefaults = [ + 'max_retries' => 3, + 'delay' => 1000, + 'max_delay' => 30_000, + 'status_codes' => [429, 503, 504], + 'curl_errors' => false, + 'respect_retry_after' => true, + ]; + + /** + * cURL error numbers that may succeed on another attempt. + * + * @var list + */ + protected array $transientCurlErrors = []; + /** * The number of milliseconds to delay before * sending the request. @@ -90,6 +111,11 @@ class CURLRequest extends OutgoingRequest */ protected $delay = 0.0; + /** + * The last cURL error number. + */ + protected int $lastCurlError = 0; + /** * The default options from the constructor. Applied to all requests. */ @@ -115,7 +141,11 @@ class CURLRequest extends OutgoingRequest * - timeout * - any other request options to use as defaults. * + * @todo v4.8.0 Remove $config parameter since unused + * * @param array $options + * + * @phpstan-ignore-next-line constructor.unusedParameter */ public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = []) { @@ -123,9 +153,17 @@ public function __construct(App $config, URI $uri, ?ResponseInterface $response throw HTTPException::forMissingCurl(); // @codeCoverageIgnore } + $this->transientCurlErrors = [ + CURLE_COULDNT_RESOLVE_HOST, + CURLE_COULDNT_CONNECT, + CURLE_OPERATION_TIMEDOUT, + CURLE_SEND_ERROR, + CURLE_RECV_ERROR, + ]; + parent::__construct(Method::GET, $uri); - $this->responseOrig = $response ?? new Response($config); + $this->responseOrig = $response ?? new Response(); // Remove the default Content-Type header. $this->responseOrig->removeHeader('Content-Type'); @@ -309,7 +347,7 @@ public function setJSON($data) protected function parseOptions(array $options) { if (array_key_exists('baseURI', $options)) { - $this->baseURI = $this->baseURI->setURI($options['baseURI']); + $this->baseURI = new URI($options['baseURI'], true); unset($options['baseURI']); } @@ -370,6 +408,8 @@ public function send(string $method, string $url) { // Reset our curl options so we're on a fresh slate. $curlOptions = []; + $config = $this->config; + $retry = $this->normalizeRetryOption($config['retry'] ?? false); if (! empty($this->config['query']) && is_array($this->config['query'])) { // This is likely too naive a solution. @@ -390,15 +430,76 @@ public function send(string $method, string $url) // Disable @file uploads in post data. $curlOptions[CURLOPT_SAFE_UPLOAD] = true; - $curlOptions = $this->setCURLOptions($curlOptions, $this->config); + $curlOptions = $this->setCURLOptions($curlOptions, $config); $curlOptions = $this->applyMethod($method, $curlOptions); $curlOptions = $this->applyRequestHeaders($curlOptions); + if ($retry !== null) { + $curlOptions[CURLOPT_FAILONERROR] = false; + } + // Do we need to delay this request? if ($this->delay > 0) { - usleep((int) $this->delay * 1_000_000); + $this->sleep($this->delay); + } + + if ($retry === null) { + return $this->sendAttempt($curlOptions); } + $httpErrors = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true; + + return $this->sendWithRetries($curlOptions, $retry, $httpErrors); + } + + /** + * Sends the request until it succeeds or retry attempts are exhausted. + * + * @param array $curlOptions + * @param array> $retry + */ + protected function sendWithRetries(array $curlOptions, array $retry, bool $httpErrors): ResponseInterface + { + $attempt = 0; + + while (true) { + $this->response = clone $this->responseOrig; + + try { + $response = $this->sendAttempt($curlOptions); + } catch (HTTPException $e) { + if (! $this->shouldRetryCurlError($retry, $attempt)) { + throw $e; + } + + $this->sleep($this->getRetryDelay($retry, $attempt) / 1000); + $attempt++; + + continue; + } + + if (! $this->shouldRetryResponse($response, $retry, $attempt)) { + if ($httpErrors && $response->getStatusCode() >= 400) { + throw HTTPException::forCurlError((string) CURLE_HTTP_RETURNED_ERROR, 'The requested URL returned error: ' . $response->getStatusCode()); + } + + return $response; + } + + $this->sleep($this->getRetryDelay($retry, $attempt, $response) / 1000); + $attempt++; + } + } + + /** + * Sends a single cURL request attempt and populates the response. + * + * @param array $curlOptions + */ + protected function sendAttempt(array $curlOptions): ResponseInterface + { + $this->lastCurlError = 0; + $output = $this->sendRequest($curlOptions); // Set the string we want to break our response from @@ -426,6 +527,158 @@ public function send(string $method, string $url) return $this->response; } + /** + * Normalizes the retry option into retry settings. + * + * @return array>|null + */ + protected function normalizeRetryOption(mixed $retry): ?array + { + if (in_array($retry, [false, null, 0], true)) { + return null; + } + + $config = $this->retryDefaults; + + if (is_int($retry)) { + $config['max_retries'] = $retry; + } elseif (is_array($retry)) { + $config = array_merge($config, $retry); + } else { + return null; + } + + $config['max_retries'] = max(0, (int) $config['max_retries']); + + if ($config['max_retries'] === 0) { + return null; + } + + $config['delay'] = $this->normalizeRetryDelay($config['delay']); + $config['max_delay'] = max(0, (int) $config['max_delay']); + $config['status_codes'] = array_map(intval(...), (array) $config['status_codes']); + $config['curl_errors'] = (bool) $config['curl_errors']; + $config['respect_retry_after'] = (bool) $config['respect_retry_after']; + + return $config; + } + + /** + * Normalizes the retry delay setting. + * + * @return int|list + */ + protected function normalizeRetryDelay(mixed $delay): array|int + { + if (is_array($delay)) { + return array_map(static fn ($value): int => max(0, (int) $value), $delay); + } + + return max(0, (int) $delay); + } + + /** + * Determines whether a response should be retried. + * + * @param array> $retry + */ + protected function shouldRetryResponse(ResponseInterface $response, array $retry, int $attempt): bool + { + if ($attempt >= $retry['max_retries']) { + return false; + } + + return in_array($response->getStatusCode(), $retry['status_codes'], true); + } + + /** + * Determines whether a cURL error should be retried. + * + * @param array> $retry + */ + protected function shouldRetryCurlError(array $retry, int $attempt): bool + { + if ($attempt >= $retry['max_retries'] || $retry['curl_errors'] === false) { + return false; + } + + return in_array($this->lastCurlError, $this->transientCurlErrors, true); + } + + /** + * Returns the delay before the next retry attempt. + * + * @param array> $retry + */ + protected function getRetryDelay(array $retry, int $attempt, ?ResponseInterface $response = null): int + { + if ($response instanceof ResponseInterface && $retry['respect_retry_after'] === true) { + $retryAfter = $this->getRetryAfterDelay($response); + + if ($retryAfter !== null) { + return $this->limitRetryDelay($retryAfter * 1000, $retry); + } + } + + $delay = $retry['delay']; + + if (is_array($delay)) { + $lastDelay = $delay[array_key_last($delay)] ?? 0; + + return $this->limitRetryDelay((int) ($delay[$attempt] ?? $lastDelay), $retry); + } + + return $this->limitRetryDelay((int) $delay, $retry); + } + + /** + * Caps the retry delay when configured. + * + * @param array> $retry + */ + protected function limitRetryDelay(int $delay, array $retry): int + { + $maxDelay = (int) $retry['max_delay']; + + if ($maxDelay === 0) { + return $delay; + } + + return min($delay, $maxDelay); + } + + /** + * Returns the delay from a Retry-After header in seconds. + */ + protected function getRetryAfterDelay(ResponseInterface $response): ?int + { + $retryAfter = $response->getHeaderLine('Retry-After'); + + if ($retryAfter === '') { + return null; + } + + if (ctype_digit($retryAfter)) { + return (int) $retryAfter; + } + + $timestamp = strtotime($retryAfter); + + if ($timestamp === false) { + return null; + } + + return max(0, $timestamp - time()); + } + + /** + * Sleeps for the configured number of seconds. + */ + protected function sleep(float $seconds): void + { + usleep((int) ($seconds * 1_000_000)); + } + /** * Adds $this->headers to the cURL request. */ @@ -727,7 +980,9 @@ protected function sendRequest(array $curlOptions = []): string $output = curl_exec($ch); if ($output === false) { - throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch)); + $this->lastCurlError = curl_errno($ch); + + throw HTTPException::forCurlError((string) $this->lastCurlError, curl_error($ch)); } return $output; diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index c94fed4c8e73..b674383e5470 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -298,6 +298,16 @@ class ContentSecurityPolicy */ protected $scriptNonce; + /** + * Whether to enable nonce to style-src and style-src-elem directives or not. + */ + protected bool $enableStyleNonce = true; + + /** + * Whether to enable nonce to script-src and script-src-elem directives or not. + */ + protected bool $enableScriptNonce = true; + /** * Nonce placeholder for style tags. * @@ -392,11 +402,33 @@ public function enabled(): bool return $this->CSPEnabled; } + /** + * Whether adding nonce in style-* directives is enabled or not. + */ + public function styleNonceEnabled(): bool + { + return $this->enabled() && $this->enableStyleNonce; + } + + /** + * Whether adding nonce in script-* directives is enabled or not. + */ + public function scriptNonceEnabled(): bool + { + return $this->enabled() && $this->enableScriptNonce; + } + /** * Get the nonce for the style tag. */ public function getStyleNonce(): string { + if (! $this->enableStyleNonce) { + $this->styleNonce = null; + + return ''; + } + if ($this->styleNonce === null) { $this->styleNonce = base64_encode(random_bytes(12)); $this->addStyleSrc('nonce-' . $this->styleNonce); @@ -414,6 +446,12 @@ public function getStyleNonce(): string */ public function getScriptNonce(): string { + if (! $this->enableScriptNonce) { + $this->scriptNonce = null; + + return ''; + } + if ($this->scriptNonce === null) { $this->scriptNonce = base64_encode(random_bytes(12)); $this->addScriptSrc('nonce-' . $this->scriptNonce); @@ -868,6 +906,30 @@ public function addReportingEndpoints(array $endpoint): static return $this; } + /** + * Enables or disables adding nonces to style-src and style-src-elem directives. + * + * @return $this + */ + public function setEnableStyleNonce(bool $value = true): static + { + $this->enableStyleNonce = $value; + + return $this; + } + + /** + * Enables or disables adding nonces to script-src and script-src-elem directives. + * + * @return $this + */ + public function setEnableScriptNonce(bool $value = true): static + { + $this->enableScriptNonce = $value; + + return $this; + } + /** * DRY method to add an string or array to a class property. * @@ -919,8 +981,21 @@ protected function generateNonces(ResponseInterface $response) return ''; } - $nonce = $match[0] === $this->styleNonceTag ? $this->getStyleNonce() : $this->getScriptNonce(); - $attr = 'nonce="' . $nonce . '"'; + if ($match[0] === $this->styleNonceTag) { + if (! $this->enableStyleNonce) { + return ''; + } + + $nonce = $this->getStyleNonce(); + } else { + if (! $this->enableScriptNonce) { + return ''; + } + + $nonce = $this->getScriptNonce(); + } + + $attr = 'nonce="' . $nonce . '"'; return $jsonEscape ? str_replace('"', '\\"', $attr) : $attr; }, $body); diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index b7ce79b8309a..5dd41d571f25 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -15,7 +15,6 @@ use CodeIgniter\Exceptions\DownloadException; use CodeIgniter\Files\File; -use Config\App; use Config\Mimes; /** @@ -23,7 +22,7 @@ * * @see \CodeIgniter\HTTP\DownloadResponseTest */ -class DownloadResponse extends Response +class DownloadResponse extends Response implements NonBufferedResponseInterface { /** * Download file name @@ -64,12 +63,9 @@ class DownloadResponse extends Response */ protected $statusCode = 200; - /** - * Constructor. - */ public function __construct(string $filename, bool $setMime) { - parent::__construct(config(App::class)); + parent::__construct(); $this->filename = $filename; $this->setMime = $setMime; @@ -251,7 +247,7 @@ public function noCache(): self public function send() { // Turn off output buffering completely, even if php.ini output_buffering is not off - if (ENVIRONMENT !== 'testing') { + if (! service('environment')->isTesting()) { while (ob_get_level() > 0) { ob_end_clean(); } diff --git a/system/HTTP/Exceptions/FormRequestException.php b/system/HTTP/Exceptions/FormRequestException.php new file mode 100644 index 000000000000..5daae9a48d1d --- /dev/null +++ b/system/HTTP/Exceptions/FormRequestException.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\HTTP\ResponsableInterface; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * @internal + */ +final class FormRequestException extends RuntimeException implements ResponsableInterface +{ + public function __construct(private readonly ResponseInterface $response) + { + parent::__construct('FormRequest authorization or validation failed.'); + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } +} diff --git a/system/HTTP/Exceptions/HTTPException.php b/system/HTTP/Exceptions/HTTPException.php index 332fd8ab9524..c268e72ce456 100644 --- a/system/HTTP/Exceptions/HTTPException.php +++ b/system/HTTP/Exceptions/HTTPException.php @@ -217,20 +217,6 @@ public static function forMoveFailed(string $source, string $target, string $err return new static(lang('HTTP.moveFailed', [$source, $target, $error])); } - /** - * For Invalid SameSite attribute setting - * - * @return HTTPException - * - * @deprecated Use `CookieException::forInvalidSameSite()` instead. - * - * @codeCoverageIgnore - */ - public static function forInvalidSameSiteSetting(string $samesite) - { - return new static(lang('Security.invalidSameSiteSetting', [$samesite])); - } - /** * Thrown when the JSON format is not supported. * This is specifically for cases where data validation is expected to work with key-value structures. diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php new file mode 100644 index 000000000000..881824e18acf --- /dev/null +++ b/system/HTTP/FormRequest.php @@ -0,0 +1,286 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\Input\ValidatedInput; +use ReflectionNamedType; +use ReflectionParameter; + +/** + * @see \CodeIgniter\HTTP\FormRequestTest + */ +abstract class FormRequest +{ + /** + * The underlying HTTP request instance. + */ + protected IncomingRequest $request; + + /** + * Data that passed validation (only the fields covered by rules()). + * + * @var array + */ + private array $validatedData = []; + + /** + * When called by the framework, the current IncomingRequest is injected + * explicitly. When instantiated manually (e.g. in tests), the constructor + * falls back to service('request'). + */ + final public function __construct(?Request $request = null) + { + $request ??= service('request'); + + if (! $request instanceof IncomingRequest) { + throw new RuntimeException( + sprintf('%s requires an IncomingRequest instance, got %s.', static::class, $request::class), + ); + } + + $this->request = $request; + } + + /** + * Validation rules for this request. + * + * Return an array of field => rules pairs, identical to what you would + * pass to $this->validate() in a controller: + * + * return [ + * 'title' => 'required|min_length[5]', + * 'body' => ['required', 'max_length[10000]'], + * ]; + * + * @return array|string> + */ + abstract public function rules(): array; + + /** + * Custom error messages keyed by field.rule. + * + * return [ + * 'title' => ['required' => 'Post title cannot be empty.'], + * ]; + * + * @return array> + */ + public function messages(): array + { + return []; + } + + /** + * Determine if the current user is authorized to make this request. + * + * Override in subclasses to add authorization logic: + * + * public function isAuthorized(): bool + * { + * return auth()->user()->can('create-posts'); + * } + */ + public function isAuthorized(): bool + { + return true; + } + + /** + * Returns the class name when the given reflection parameter is typed as a + * FormRequest subclass, or null otherwise. Used by the dispatcher and + * auto-router to distinguish injectable parameters from URI-segment parameters. + * + * @internal + * + * @return class-string|null + */ + final public static function getFormRequestClass(ReflectionParameter $param): ?string + { + $type = $param->getType(); + + if ( + $type instanceof ReflectionNamedType + && ! $type->isBuiltin() + && is_subclass_of($type->getName(), self::class) + ) { + return $type->getName(); + } + + return null; + } + + /** + * Modify the request data before validation rules are applied. + * Override to normalize or cast input values: + * + * protected function prepareForValidation(array $data): array + * { + * $data['slug'] = url_title($data['title'] ?? '', '-', true); + * return $data; + * } + * + * The $data array is the same payload that will be passed to the + * validator. Return the (possibly modified) array. + * + * @param array $data + * + * @return array + */ + protected function prepareForValidation(array $data): array + { + return $data; + } + + /** + * Called when validation fails. Override to customize the failure response. + * + * The default implementation redirects back with input and flashes validation + * errors via the standard ``_ci_validation_errors`` channel (the same channel + * used by controller-level validation and readable by ``validation_errors()`` + * helpers). For JSON requests or requests that prefer JSON responses, it + * returns a 422 JSON response instead. + * + * @param array $errors + * @param array $preparedData + */ + protected function failedValidation(array $errors, array $preparedData): ResponseInterface + { + if ($this->shouldReturnJsonResponse()) { + return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); + } + + $redirect = redirect()->back()->withInput(); + + $key = in_array($this->request->getMethod(), [Method::GET, Method::HEAD], true) + ? 'get' + : 'post'; + + $oldInput = [ + 'get' => service('superglobals')->getGetArray(), + 'post' => service('superglobals')->getPostArray(), + ]; + $oldInput[$key] = $preparedData; + + service('session')->setFlashdata('_ci_old_input', $oldInput); + + return $redirect; + } + + /** + * Determine whether the default validation failure response should be JSON. + */ + private function shouldReturnJsonResponse(): bool + { + return $this->request->is('json') + || $this->request->negotiate('media', ['text/html', 'application/json'], true) === 'application/json'; + } + + /** + * Called when the isAuthorized() check returns false. Override to customize. + */ + protected function failedAuthorization(): ResponseInterface + { + return service('response')->setStatusCode(403); + } + + /** + * Returns only the fields that passed validation (those covered by rules()). + * + * Prefer this over $this->request->getPost() in controllers to avoid + * processing fields that were not declared in the rules. + * + * @return array + */ + public function getValidated(): array + { + return $this->validatedData; + } + + /** + * Returns the validated data as a typed input object. + */ + public function getValidatedInput(): ValidatedInput + { + return service('inputdatafactory')->createValidated($this->validatedData); + } + + /** + * Returns the data to be validated. + * + * Override this method to provide custom data or to merge data from + * multiple sources. By default, data is sourced from the appropriate + * part of the request based on HTTP method and Content-Type: + * + * - JSON (any method) - decoded JSON body + * - PUT / PATCH / DELETE - raw body (unless multipart/form-data) + * - GET / HEAD - query-string parameters + * - Everything else (POST) - POST body + * + * @return array + */ + protected function validationData(): array + { + $contentType = $this->request->getHeaderLine('Content-Type'); + + if (str_contains($contentType, 'application/json')) { + return $this->request->getJSON(true) ?? []; + } + + if ( + in_array($this->request->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true) + && ! str_contains($contentType, 'multipart/form-data') + ) { + return $this->request->getRawInput() ?? []; + } + + if (in_array($this->request->getMethod(), [Method::GET, Method::HEAD], true)) { + return $this->request->getGet() ?? []; + } + + return $this->request->getPost() ?? []; + } + + /** + * Runs authorization and validation. Called by the framework before + * injecting the FormRequest into the controller method. + * + * Returns null on success, or a ResponseInterface to short-circuit the + * request when authorization or validation fails. + * + * Do not call this method directly unless you are inside a ``_remap()`` + * method, where automatic injection is not available. + */ + final public function resolveRequest(): ?ResponseInterface + { + $this->validatedData = []; + + if (! $this->isAuthorized()) { + return $this->failedAuthorization(); + } + + $data = $this->prepareForValidation($this->validationData()); + + $validation = service('validation') + ->setRules($this->rules(), $this->messages()); + + if (! $validation->run($data)) { + return $this->failedValidation($validation->getErrors(), $data); + } + + $this->validatedData = $validation->getValidated(); + + return null; + } +} diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index a4a4c357ac74..3139af042608 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -123,6 +123,11 @@ class IncomingRequest extends Request */ protected $userAgent; + /** + * Typed input data selector. + */ + protected ?RequestInput $input = null; + /** * Constructor * @@ -169,6 +174,11 @@ public function __construct($config, ?URI $uri = null, $body = 'php://input', ?U $this->detectLocale($config); } + public function __clone() + { + $this->input = null; + } + private function getPostMaxSize(): int { $postMaxSize = ini_get('post_max_size'); @@ -569,6 +579,14 @@ public function getGet($index = null, $filter = null, $flags = null) return $this->fetchGlobal('get', $index, $filter, $flags); } + /** + * Returns a typed input data selector. + */ + public function input(): RequestInput + { + return $this->input ??= new RequestInput($this, service('inputdatafactory')); + } + /** * Fetch an item from POST. * diff --git a/system/HTTP/Message.php b/system/HTTP/Message.php index 5a490e09f780..bb319bf82bc1 100644 --- a/system/HTTP/Message.php +++ b/system/HTTP/Message.php @@ -60,39 +60,6 @@ public function getBody() return $this->body; } - /** - * Returns an array containing all headers. - * - * @return array An array of the request headers - * - * @deprecated Use Message::headers() to make room for PSR-7 - * - * @TODO Incompatible return value with PSR-7 - * - * @codeCoverageIgnore - */ - public function getHeaders(): array - { - return $this->headers(); - } - - /** - * Returns a single header object. If multiple headers with the same - * name exist, then will return an array of header objects. - * - * @return array|Header|null - * - * @deprecated Use Message::header() to make room for PSR-7 - * - * @TODO Incompatible return value with PSR-7 - * - * @codeCoverageIgnore - */ - public function getHeader(string $name) - { - return $this->header($name); - } - /** * Determines whether a header exists. */ diff --git a/system/HTTP/NonBufferedResponseInterface.php b/system/HTTP/NonBufferedResponseInterface.php new file mode 100644 index 000000000000..06f70ce4dd2a --- /dev/null +++ b/system/HTTP/NonBufferedResponseInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +/** + * Marker interface for responses that bypass output buffering + * and send their body directly to the client (e.g. downloads, SSE streams). + */ +interface NonBufferedResponseInterface +{ +} diff --git a/system/HTTP/OutgoingRequest.php b/system/HTTP/OutgoingRequest.php index eba5babdf6bb..11ec0b13e89c 100644 --- a/system/HTTP/OutgoingRequest.php +++ b/system/HTTP/OutgoingRequest.php @@ -82,7 +82,7 @@ public function getMethod(): string * * @return $this * - * @deprecated Use withMethod() instead for immutability + * @deprecated 4.3.0 Use withMethod() instead for immutability */ public function setMethod(string $method) { diff --git a/system/HTTP/RequestInput.php b/system/HTTP/RequestInput.php new file mode 100644 index 000000000000..4c94949cbe4f --- /dev/null +++ b/system/HTTP/RequestInput.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Input\InputData; +use CodeIgniter\Input\InputDataFactory; + +/** + * Provides typed input access for request data sources. + * + * @see \CodeIgniter\HTTP\RequestInputTest + */ +final readonly class RequestInput +{ + public function __construct( + private IncomingRequest $request, + private InputDataFactory $factory, + ) { + } + + /** + * Returns GET parameters as a typed input object. + */ + public function get(): InputData + { + $data = $this->request->getGet(); + + return $this->factory->create(is_array($data) ? $data : []); + } + + /** + * Returns POST body parameters as a typed input object. + */ + public function post(): InputData + { + $data = $this->request->getPost(); + + return $this->factory->create(is_array($data) ? $data : []); + } + + /** + * Returns JSON body parameters as a typed input object. + */ + public function json(): InputData + { + $data = $this->request->getJSON(true) ?? []; + + if (! is_array($data)) { + throw HTTPException::forUnsupportedJSONFormat(); + } + + return $this->factory->create($data); + } + + /** + * Returns raw input parameters as a typed input object. + */ + public function raw(): InputData + { + return $this->factory->create($this->request->getRawInput()); + } +} diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index 67c5382adc1c..e53a6a4056b1 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -36,12 +36,8 @@ trait RequestTrait /** * IP address of the current user. - * - * @var string - * - * @deprecated Will become private in a future release */ - protected $ipAddress = ''; + private string $ipAddress = ''; /** * Stores values we've retrieved from PHP globals. @@ -78,11 +74,11 @@ public function getIPAddress(): string ); } - $this->ipAddress = $this->getServer('REMOTE_ADDR'); + $this->ipAddress = $this->getServer('REMOTE_ADDR') ?? '0.0.0.0'; - // If this is a CLI request, $this->ipAddress is null. - if ($this->ipAddress === null) { - return $this->ipAddress = '0.0.0.0'; + // If this is a CLI request, $this->ipAddress is '0.0.0.0'. + if ($this->ipAddress === '0.0.0.0') { + return $this->ipAddress; } // @TODO Extract all this IP address logic to another class. @@ -206,23 +202,6 @@ public function getServer($index = null, $filter = null, $flags = null) return $this->fetchGlobal('server', $index, $filter, $flags); } - /** - * Fetch an item from the $_ENV array. - * - * @param array|string|null $index Index for item to be fetched from $_ENV - * @param int|null $filter A filter name to be applied - * @param array|int|null $flags - * - * @return mixed - * - * @deprecated 4.4.4 This method does not work from the beginning. Use `env()`. - */ - public function getEnv($index = null, $filter = null, $flags = null) - { - // @phpstan-ignore-next-line - return $this->fetchGlobal('env', $index, $filter, $flags); - } - /** * Allows manually setting the value of PHP global, like $_GET, $_POST, etc. * diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index 3c59f2e9800d..59474ad0938d 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -14,10 +14,7 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Cookie\Cookie; -use CodeIgniter\Cookie\CookieStore; use CodeIgniter\HTTP\Exceptions\HTTPException; -use Config\App; -use Config\Cookie as CookieConfig; /** * Representation of an outgoing, server-side response. @@ -39,7 +36,7 @@ class Response extends Message implements ResponseInterface /** * HTTP status codes * - * @var array + * @var array */ protected static $statusCodes = [ // 1xx: Informational @@ -142,31 +139,13 @@ class Response extends Message implements ResponseInterface protected $pretend = false; /** - * Constructor - * - * @param App $config - * - * @todo Recommend removing reliance on config injection - * - * @deprecated 4.5.0 The param $config is no longer used. + * Construct a non-caching response with a default content type of `text/html`. */ - public function __construct($config) // @phpstan-ignore-line + public function __construct() { - // Default to a non-caching page. - // Also ensures that a Cache-control header exists. - $this->noCache(); - - // We need CSP object even if not enabled to avoid calls to non existing methods - $this->CSP = service('csp'); - - $this->cookieStore = new CookieStore([]); - - $cookie = config(CookieConfig::class); - - Cookie::setDefaults($cookie); + Cookie::setDefaults(config('Cookie')); - // Default to an HTML Content-Type. Devs can override if needed. - $this->setContentType('text/html'); + $this->noCache()->setContentType('text/html'); } /** @@ -178,7 +157,6 @@ public function __construct($config) // @phpstan-ignore-line * @return $this * * @internal For testing purposes only. - * @testTag only available to test code */ public function pretend(bool $pretend = true) { diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index 265a62fb0a1f..b3c0c88da326 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; use CodeIgniter\Cookie\Cookie; use CodeIgniter\Cookie\CookieStore; use CodeIgniter\Cookie\Exceptions\CookieException; @@ -21,6 +22,8 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Pager\PagerInterface; use CodeIgniter\Security\Exceptions\SecurityException; +use Config\App; +use Config\ContentSecurityPolicy as ContentSecurityPolicyConfig; use Config\Cookie as CookieConfig; use DateTime; use DateTimeZone; @@ -31,21 +34,30 @@ * Additional methods to make a PSR-7 Response class * compliant with the framework's own ResponseInterface. * + * @property array $statusCodes + * @property string|null $body + * * @see https://github.com/php-fig/http-message/blob/master/src/ResponseInterface.php */ trait ResponseTrait { /** - * Content security policy handler + * Content security policy handler. + * + * Lazily instantiated on first use via `self::getCSP()` so that the + * ContentSecurityPolicy class is not loaded on requests that do not use CSP. * - * @var ContentSecurityPolicy + * @var ContentSecurityPolicy|null */ protected $CSP; /** * CookieStore instance. * - * @var CookieStore + * Lazily instantiated on first cookie-related call so that the Cookie and + * CookieStore classes are not loaded on requests that do not use cookies. + * + * @var CookieStore|null */ protected $cookieStore; @@ -77,19 +89,17 @@ trait ResponseTrait */ public function setStatusCode(int $code, string $reason = '') { - // Valid range? if ($code < 100 || $code > 599) { throw HTTPException::forInvalidStatusCode($code); } - // Unknown and no message? - if (! array_key_exists($code, static::$statusCodes) && ($reason === '')) { + if (! array_key_exists($code, static::$statusCodes) && $reason === '') { throw HTTPException::forUnkownStatusCode($code); } $this->statusCode = $code; - $this->reason = ($reason !== '') ? $reason : static::$statusCodes[$code]; + $this->reason = $reason !== '' ? $reason : static::$statusCodes[$code]; return $this; } @@ -366,8 +376,10 @@ public function setLastModified($date) public function send() { // If we're enforcing a Content Security Policy, - // we need to give it a chance to build out it's headers. - $this->CSP->finalize($this); + // we need to give it a chance to build out its headers. + if ($this->shouldFinalizeCsp()) { + $this->getCSP()->finalize($this); + } $this->sendHeaders(); $this->sendCookies(); @@ -376,6 +388,44 @@ public function send() return $this; } + /** + * Decides whether {@see ContentSecurityPolicy::finalize()} should run for + * this response. Keeping the CSP class unloaded on requests that do not + * need it avoids the cost of constructing a 1000+ line service on every + * request. + */ + private function shouldFinalizeCsp(): bool + { + // Developer already touched CSP through getCSP(); respect it. + if ($this->CSP !== null) { + return true; + } + + // A CSP instance has been registered (e.g., via Services::injectMock() + // or any earlier service('csp') call) — reuse it instead of skipping. + if (Services::has('csp')) { + return true; + } + + if (config(App::class)->CSPEnabled) { + return true; + } + + // Placeholders in the body still need to be stripped even when CSP + // is disabled, so the body is scanned for the configured nonce tags + // before committing to loading the full CSP class. + $body = (string) $this->body; + + if ($body === '') { + return false; + } + + $cspConfig = config(ContentSecurityPolicyConfig::class); + + return str_contains($body, $cspConfig->scriptNonceTag) + || str_contains($body, $cspConfig->styleNonceTag); + } + /** * Sends the headers of this HTTP response to the browser. * @@ -518,8 +568,10 @@ public function setCookie( $httponly = null, $samesite = null, ) { + $store = $this->getCookieStore(); + if ($name instanceof Cookie) { - $this->cookieStore = $this->cookieStore->put($name); + $this->cookieStore = $store->put($name); return $this; } @@ -553,7 +605,7 @@ public function setCookie( 'samesite' => $samesite ?? '', ]); - $this->cookieStore = $this->cookieStore->put($cookie); + $this->cookieStore = $store->put($cookie); return $this; } @@ -561,10 +613,15 @@ public function setCookie( /** * Returns the `CookieStore` instance. * + * Lazily instantiates the `CookieStore` on first call, so that the Cookie and + * CookieStore classes are not loaded on requests that do not use cookies. + * * @return CookieStore */ public function getCookieStore() { + $this->cookieStore ??= new CookieStore([]); + return $this->cookieStore; } @@ -573,9 +630,10 @@ public function getCookieStore() */ public function hasCookie(string $name, ?string $value = null, string $prefix = ''): bool { + $store = $this->getCookieStore(); $prefix = $prefix !== '' ? $prefix : Cookie::setDefaults()['prefix']; // to retain BC - return $this->cookieStore->has($name, $prefix, $value); + return $store->has($name, $prefix, $value); } /** @@ -588,14 +646,16 @@ public function hasCookie(string $name, ?string $value = null, string $prefix = */ public function getCookie(?string $name = null, string $prefix = '') { + $store = $this->getCookieStore(); + if ((string) $name === '') { - return $this->cookieStore->display(); + return $store->display(); } try { $prefix = $prefix !== '' ? $prefix : Cookie::setDefaults()['prefix']; // to retain BC - return $this->cookieStore->get($name, $prefix); + return $store->get($name, $prefix); } catch (CookieException $e) { log_message('error', (string) $e); @@ -614,10 +674,10 @@ public function deleteCookie(string $name = '', string $domain = '', string $pat return $this; } + $store = $this->getCookieStore(); $prefix = $prefix !== '' ? $prefix : Cookie::setDefaults()['prefix']; // to retain BC $prefixed = $prefix . $name; - $store = $this->cookieStore; $found = false; /** @var Cookie $cookie */ @@ -653,6 +713,10 @@ public function deleteCookie(string $name = '', string $domain = '', string $pat */ public function getCookies() { + if ($this->cookieStore === null) { + return []; + } + return $this->cookieStore->display(); } @@ -663,7 +727,7 @@ public function getCookies() */ protected function sendCookies() { - if ($this->pretend) { + if ($this->pretend || $this->cookieStore === null) { return; } @@ -748,6 +812,8 @@ public function download(string $filename = '', $data = '', bool $setMime = fals public function getCSP(): ContentSecurityPolicy { + $this->CSP ??= service('csp'); + return $this->CSP; } } diff --git a/system/HTTP/SSEResponse.php b/system/HTTP/SSEResponse.php new file mode 100644 index 000000000000..bc86f82e82f8 --- /dev/null +++ b/system/HTTP/SSEResponse.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use Closure; +use JsonException; + +/** + * HTTP response for Server-Sent Events (SSE) streaming. + * + * @see \CodeIgniter\HTTP\SSEResponseTest + */ +class SSEResponse extends Response implements NonBufferedResponseInterface +{ + /** + * Constructor. + * + * @param Closure(SSEResponse): void $callback + */ + public function __construct(private readonly Closure $callback) + { + parent::__construct(); + } + + /** + * Send an SSE event to the client. + * + * @param array|string $data Event data (arrays are JSON-encoded) + * @param string|null $event Event type + * @param string|null $id Event ID + */ + public function event(array|string $data, ?string $event = null, ?string $id = null): bool + { + if ($this->isConnectionAborted()) { + return false; + } + + $output = ''; + + if ($event !== null) { + $output .= 'event: ' . $this->sanitizeLine($event) . "\n"; + } + + if ($id !== null) { + $output .= 'id: ' . $this->sanitizeLine($id) . "\n"; + } + + if (is_array($data)) { + try { + $data = json_encode($data, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + log_message('error', 'SSE JSON encode failed: {message}', ['message' => $e->getMessage()]); + + return false; + } + } + + $output .= $this->formatMultiline('data', $data); + + return $this->write($output); + } + + /** + * Send an SSE comment (useful for keep-alive). + */ + public function comment(string $text): bool + { + if ($this->isConnectionAborted()) { + return false; + } + + return $this->write($this->formatMultiline('', $text)); + } + + /** + * Set the client reconnection interval. + * + * @param int $milliseconds Retry interval in milliseconds + */ + public function retry(int $milliseconds): bool + { + if ($this->isConnectionAborted()) { + return false; + } + + return $this->write("retry: {$milliseconds}\n\n"); + } + + /** + * Check if the client connection has been lost. + */ + private function isConnectionAborted(): bool + { + return connection_status() !== CONNECTION_NORMAL || connection_aborted() === 1; + } + + /** + * Strip newlines from a single-line SSE field (event, id). + */ + private function sanitizeLine(string $value): string + { + return str_replace(["\r\n", "\r", "\n"], '', $value); + } + + /** + * Format a value as prefixed SSE lines, normalizing line endings. + * + * Each line becomes "{prefix}: {line}\n", terminated by an extra "\n". + */ + private function formatMultiline(string $prefix, string $value): string + { + $value = str_replace(["\r\n", "\r"], "\n", $value); + $output = ''; + + foreach (explode("\n", $value) as $line) { + $output .= "{$prefix}: " . $line . "\n"; + } + + return $output . "\n"; + } + + /** + * Write raw SSE output and flush. + */ + private function write(string $output): bool + { + echo $output; + + if (! service('environment')->isTesting()) { + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @return $this + */ + public function send() + { + // Turn off output buffering completely, even if php.ini output_buffering is not off + if (! service('environment')->isTesting()) { + set_time_limit(0); + ini_set('zlib.output_compression', 'Off'); + + while (ob_get_level() > 0) { + ob_end_clean(); + } + } + + // Close session if active to prevent blocking other requests + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + $this->setContentType('text/event-stream', 'UTF-8'); + $this->removeHeader('Cache-Control'); + $this->setHeader('Cache-Control', 'no-cache'); + $this->setHeader('Content-Encoding', 'identity'); + $this->setHeader('X-Accel-Buffering', 'no'); + + // Connection: keep-alive is only valid for HTTP/1.x + if (version_compare($this->getProtocolVersion(), '2.0', '<')) { + $this->setHeader('Connection', 'keep-alive'); + } + + // Intentionally skip CSP finalize: no HTML/JS execution in SSE streams. + $this->sendHeaders(); + $this->sendCookies(); + + ($this->callback)($this); + + return $this; + } + + /** + * {@inheritDoc} + * + * No-op — body is streamed via the callback, not stored. + * + * @return $this + */ + public function sendBody() + { + return $this; + } +} diff --git a/system/HTTP/SiteURI.php b/system/HTTP/SiteURI.php index d6653b10f537..dc4224455fc6 100644 --- a/system/HTTP/SiteURI.php +++ b/system/HTTP/SiteURI.php @@ -13,7 +13,6 @@ namespace CodeIgniter\HTTP; -use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; @@ -53,26 +52,10 @@ class SiteURI extends URI * 1 => 'public', * 2 => 'index.php', * ]; - */ - private array $baseSegments; - - /** - * List of URI segments after indexPage. - * - * The word "URI Segments" originally means only the URI path part relative - * to the baseURL. - * - * If the URI is "http://localhost:8888/ci431/public/index.php/test?a=b", - * and the baseURL is "http://localhost:8888/ci431/public/", then: - * $segments = [ - * 0 => 'test', - * ]; * - * @var array - * - * @deprecated This property will be private. + * @var list */ - protected $segments; + private array $baseSegments; /** * URI path relative to baseURL. @@ -149,9 +132,9 @@ private function determineBaseURL( // Update scheme if ($scheme !== null && $scheme !== '') { - $uri->setScheme($scheme); + $uri = $uri->withScheme($scheme); } elseif ($configApp->forceGlobalSecureRequests) { - $uri->setScheme('https'); + $uri = $uri->withScheme('https'); } // Update host @@ -219,26 +202,10 @@ private function setBasePath(): void } } - /** - * @deprecated - */ - public function setBaseURL(string $baseURL): void - { - throw new BadMethodCallException('Cannot use this method.'); - } - - /** - * @deprecated - */ - public function setURI(?string $uri = null) - { - throw new BadMethodCallException('Cannot use this method.'); - } - /** * Returns the baseURL. * - * @interal + * @internal */ public function getBaseURL(): string { @@ -298,23 +265,21 @@ private function setRoutePath(string $routePath): void } /** - * Converts path to segments + * @return list */ private function convertToSegments(string $path): array { $tempPath = trim($path, '/'); - return ($tempPath === '') ? [] : explode('/', $tempPath); + return $tempPath === '' ? [] : explode('/', $tempPath); } /** * Sets the path portion of the URI based on segments. * * @return $this - * - * @deprecated This method will be private. */ - public function refreshPath() + protected function refreshPath(): self { $allSegments = array_merge($this->baseSegments, $this->segments); $this->path = '/' . $this->filterPath(implode('/', $allSegments)); @@ -364,11 +329,7 @@ protected function applyParts(array $parts): void $this->fragment = $parts['fragment']; } - if (isset($parts['scheme'])) { - $this->setScheme(rtrim($parts['scheme'], ':/')); - } else { - $this->setScheme('http'); - } + $this->scheme = $this->withScheme($parts['scheme'] ?? 'http')->getScheme(); if (isset($parts['port'])) { // Valid port numbers are enforced by earlier parse_url or setPort() diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index bc848ba5c73e..d15172ffb0b6 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -13,7 +13,6 @@ namespace CodeIgniter\HTTP; -use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; @@ -37,22 +36,6 @@ class URI implements Stringable */ public const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; - /** - * Current URI string - * - * @var string - * - * @deprecated 4.4.0 Not used. - */ - protected $uriString; - - /** - * The Current baseURL. - * - * @deprecated 4.4.0 Use SiteURI instead. - */ - private ?string $baseURL = null; - /** * List of URI segments. * @@ -260,9 +243,15 @@ public static function removeDotSegments(string $path): string * @TODO null for param $uri should be removed. * See https://www.php-fig.org/psr/psr-17/#26-urifactoryinterface */ - public function __construct(?string $uri = null) + public function __construct(?string $uri = null, bool $useRawQueryString = false) { - $this->setURI($uri); + $this->useRawQueryString($useRawQueryString); + + if ($uri === null) { + return; + } + + $this->setUri($uri); } /** @@ -275,6 +264,8 @@ public function __construct(?string $uri = null) */ public function setSilent(bool $silent = true) { + @trigger_error(sprintf('The %s method is deprecated and will be removed in CodeIgniter 5.0.', __METHOD__), E_USER_DEPRECATED); + $this->silent = $silent; return $this; @@ -298,18 +289,10 @@ public function useRawQueryString(bool $raw = true) /** * Sets and overwrites any current URI information. * - * @return URI - * * @throws HTTPException - * - * @deprecated 4.4.0 This method will be private. */ - public function setURI(?string $uri = null) + private function setUri(string $uri): self { - if ($uri === null) { - return $this; - } - $parts = parse_url($uri); if (is_array($parts)) { @@ -336,9 +319,7 @@ public function setURI(?string $uri = null) * The trailing ":" character is not part of the scheme and MUST NOT be * added. * - * @see https://tools.ietf.org/html/rfc3986#section-3.1 - * - * @return string The URI scheme. + * @see https://tools.ietf.org/html/rfc3986#section-3.1 */ public function getScheme(): string { @@ -657,7 +638,7 @@ public function __toString(): string * * @return array{string, string} * - * @deprecated This method will be deleted. + * @deprecated 4.2.0 This method will be deleted. */ private function changeSchemeAndPath(string $scheme, string $path): array { @@ -711,26 +692,6 @@ public function setAuthority(string $str) return $this; } - /** - * Sets the scheme for this URI. - * - * Because of the large number of valid schemes we cannot limit this - * to only http or https. - * - * @see https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml - * - * @return $this - * - * @deprecated 4.4.0 Use `withScheme()` instead. - */ - public function setScheme(string $str) - { - $str = strtolower($str); - $this->scheme = preg_replace('#:(//)?$#', '', $str); - - return $this; - } - /** * Return an instance with the specified scheme. * @@ -835,42 +796,12 @@ public function setPath(string $path) return $this; } - /** - * Sets the current baseURL. - * - * @interal - * - * @deprecated Use SiteURI instead. - */ - public function setBaseURL(string $baseURL): void - { - $this->baseURL = $baseURL; - } - - /** - * Returns the current baseURL. - * - * @interal - * - * @deprecated Use SiteURI instead. - */ - public function getBaseURL(): string - { - if ($this->baseURL === null) { - throw new BadMethodCallException('The $baseURL is not set.'); - } - - return $this->baseURL; - } - /** * Sets the path portion of the URI based on segments. * * @return $this - * - * @deprecated This method will be private. */ - public function refreshPath() + protected function refreshPath(): self { $this->path = $this->filterPath(implode('/', $this->segments)); @@ -887,7 +818,7 @@ public function refreshPath() * * @return $this * - * @TODO PSR-7: Should be `withQuery($query)`. + * @TODO Deprecated in the next major version. Use withQuery() instead for immutability. */ public function setQuery(string $query) { @@ -913,13 +844,25 @@ public function setQuery(string $query) return $this; } + /** + * Returns an instance with the specified query string. + */ + public function withQuery(string $query): static + { + $uri = clone $this; + + $uri->setQuery($query); + + return $uri; + } + /** * A convenience method to pass an array of items in as the Query * portion of the URI. * - * @return URI + * @return $this * - * @TODO: PSR-7: Should be `withQueryParams(array $query)` + * @TODO Deprecated in the next major version. Use withQueryArray() instead for immutability. */ public function setQueryArray(array $query) { @@ -928,6 +871,22 @@ public function setQueryArray(array $query) return $this->setQuery($query); } + /** + * Returns an instance with the specified query vars. + * + * Note: Method not in PSR-7 + * + * @param array $query + */ + public function withQueryArray(array $query): static + { + $uri = clone $this; + + $uri->setQueryArray($query); + + return $uri; + } + /** * Adds a single new element to the query vars. * @@ -936,6 +895,8 @@ public function setQueryArray(array $query) * @param int|string|null $value * * @return $this + * + * @TODO Deprecated in the next major version. Use withQueryVar() or withQueryVars() instead for immutability. */ public function addQuery(string $key, $value = null) { @@ -944,6 +905,38 @@ public function addQuery(string $key, $value = null) return $this; } + /** + * Returns an instance with one query var added or replaced. + * + * Note: Method not in PSR-7 + */ + public function withQueryVar(string $key, int|string|null $value): static + { + $uri = clone $this; + + $uri->query[$key] = $value; + + return $uri; + } + + /** + * Returns an instance with multiple query vars added or replaced. + * + * Note: Method not in PSR-7 + * + * @param array $params + */ + public function withQueryVars(array $params): static + { + $uri = clone $this; + + foreach ($params as $key => $value) { + $uri->query[$key] = $value; + } + + return $uri; + } + /** * Removes one or more query vars from the URI. * @@ -952,6 +945,8 @@ public function addQuery(string $key, $value = null) * @param string ...$params * * @return $this + * + * @TODO Deprecated in the next major version. Use withoutQueryVars() instead for immutability. */ public function stripQuery(...$params) { @@ -962,6 +957,20 @@ public function stripQuery(...$params) return $this; } + /** + * Returns an instance without the specified query vars. + * + * Note: Method not in PSR-7 + */ + public function withoutQueryVars(string ...$params): static + { + $uri = clone $this; + + $uri->stripQuery(...$params); + + return $uri; + } + /** * Filters the query variables so that only the keys passed in * are kept. The rest are removed from the object. @@ -971,6 +980,8 @@ public function stripQuery(...$params) * @param string ...$params * * @return $this + * + * @TODO Deprecated in the next major version. Use withOnlyQueryVars() instead for immutability. */ public function keepQuery(...$params) { @@ -989,6 +1000,20 @@ public function keepQuery(...$params) return $this; } + /** + * Returns an instance with only the specified query vars. + * + * Note: Method not in PSR-7 + */ + public function withOnlyQueryVars(string ...$params): static + { + $uri = clone $this; + + $uri->keepQuery(...$params); + + return $uri; + } + /** * Sets the fragment portion of the URI. * @@ -1077,11 +1102,7 @@ protected function applyParts(array $parts) $this->fragment = $parts['fragment']; } - if (isset($parts['scheme'])) { - $this->setScheme(rtrim($parts['scheme'], ':/')); - } else { - $this->setScheme('http'); - } + $this->scheme = $this->withScheme($parts['scheme'] ?? 'http')->getScheme(); if (isset($parts['port'])) { // Valid port numbers are enforced by earlier parse_url or setPort() @@ -1113,11 +1134,10 @@ public function resolveRelativeURI(string $uri) * NOTE: We don't use removeDotSegments in this * algorithm since it's already done by this line! */ - $relative = new self(); - $relative->setURI($uri); + $relative = new self($uri, $this->rawQueryString); if ($relative->getScheme() === $this->getScheme()) { - $relative->setScheme(''); + $relative = $relative->withScheme(''); } $transformed = clone $relative; @@ -1150,8 +1170,7 @@ public function resolveRelativeURI(string $uri) $transformed->setAuthority($this->getAuthority()); } - $transformed->setScheme($this->getScheme()); - + $transformed = $transformed->withScheme($this->getScheme()); $transformed->setFragment($relative->getFragment()); return $transformed; diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index 1e7d6904796c..d19fe83c93d2 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -13,15 +13,20 @@ namespace CodeIgniter\Helpers\Array; +use ArrayAccess; +use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\InvalidArgumentException; +use stdClass; +use Traversable; /** - * @interal This is internal implementation for the framework. + * @internal This is internal implementation for the framework. * * If there are any methods that should be provided, make them * public APIs via helper functions. * - * @see \CodeIgniter\Helpers\Array\ArrayHelperDotKeyExistsTest + * @see \CodeIgniter\Helpers\Array\ArrayHelperDotHasTest + * @see \CodeIgniter\Helpers\Array\ArrayHelperDotModifyTest * @see \CodeIgniter\Helpers\Array\ArrayHelperRecursiveDiffTest * @see \CodeIgniter\Helpers\Array\ArrayHelperSortValuesByNaturalTest */ @@ -33,11 +38,12 @@ final class ArrayHelper * * @used-by dot_array_search() * - * @param string $index The index as dot array syntax. + * @param string $index The index as dot array syntax. + * @param array|object $array * * @return mixed */ - public static function dotSearch(string $index, array $array) + public static function dotSearch(string $index, array|object $array) { return self::arraySearchDot(self::convertToArray($index), $array); } @@ -49,13 +55,22 @@ public static function dotSearch(string $index, array $array) */ private static function convertToArray(string $index): array { + $trimmed = rtrim($index, '* '); + + if ($trimmed === '') { + return []; + } + + // Fast path: no escaped dots, skip the regex entirely. + if (! str_contains($trimmed, '\\.')) { + return array_values(array_filter( + explode('.', $trimmed), + static fn ($s): bool => $s !== '', + )); + } + // See https://regex101.com/r/44Ipql/1 - $segments = preg_split( - '/(? str_replace('\.', '.', $key), @@ -68,9 +83,12 @@ private static function convertToArray(string $index): array * * @used-by dotSearch() * + * @param list $indexes + * @param array|object $array + * * @return mixed */ - private static function arraySearchDot(array $indexes, array $array) + private static function arraySearchDot(array $indexes, array|object $array) { // If index is empty, returns null. if ($indexes === []) { @@ -80,16 +98,12 @@ private static function arraySearchDot(array $indexes, array $array) // Grab the current index $currentIndex = array_shift($indexes); - if (! isset($array[$currentIndex]) && $currentIndex !== '*') { - return null; - } - // Handle Wildcard (*) if ($currentIndex === '*') { $answer = []; - foreach ($array as $value) { - if (! is_array($value)) { + foreach (self::entries($array) as $value) { + if (! self::isNavigable($value)) { return null; } @@ -106,15 +120,21 @@ private static function arraySearchDot(array $indexes, array $array) return null; } + [$found, $value] = self::resolve($array, $currentIndex); + + if (! $found) { + return null; + } + // If this is the last index, make sure to return it now, // and not try to recurse through things. if ($indexes === []) { - return $array[$currentIndex]; + return $value; } // Do we need to recursively search this value? - if (is_array($array[$currentIndex]) && $array[$currentIndex] !== []) { - return self::arraySearchDot($indexes, $array[$currentIndex]); + if ((is_array($value) && $value !== []) || is_object($value)) { + return self::arraySearchDot($indexes, $value); } // Otherwise, not found. @@ -125,53 +145,180 @@ private static function arraySearchDot(array $indexes, array $array) * array_key_exists() with dot array syntax. * * If wildcard `*` is used, all items for the key after it must have the key. + * + * @param array|object $array */ - public static function dotKeyExists(string $index, array $array): bool + public static function dotHas(string $index, array|object $array): bool { - if (str_ends_with($index, '*') || str_contains($index, '*.*')) { - throw new InvalidArgumentException( - 'You must set key right after "*". Invalid index: "' . $index . '"', - ); - } + self::ensureValidWildcardPattern($index); $indexes = self::convertToArray($index); - // If indexes is empty, returns false. if ($indexes === []) { return false; } - $currentArray = $array; + return self::hasByDotPath($array, $indexes); + } - // Grab the current index - while ($currentIndex = array_shift($indexes)) { - if ($currentIndex === '*') { - $currentIndex = array_shift($indexes); + /** + * Recursively check key existence by dot path, including wildcard support. + * + * @param array|object $array + * @param list $indexes + */ + private static function hasByDotPath(array|object $array, array $indexes): bool + { + if ($indexes === []) { + return true; + } - foreach ($currentArray as $item) { - if (! array_key_exists($currentIndex, $item)) { - return false; - } - } + $currentIndex = array_shift($indexes); - // If indexes is empty, all elements are checked. - if ($indexes === []) { - return true; + if ($currentIndex === '*') { + foreach (self::entries($array) as $item) { + if (! self::isNavigable($item) || ! self::hasByDotPath($item, $indexes)) { + return false; } + } + + return true; + } + + [$found, $value] = self::resolve($array, $currentIndex); + + if (! $found) { + return false; + } - $currentArray = self::dotSearch('*.' . $currentIndex, $currentArray); + if ($indexes === []) { + return true; + } + + if (! self::isNavigable($value)) { + return false; + } + + return self::hasByDotPath($value, $indexes); + } + + /** + * Sets a value by dot array syntax. + * + * @param array $array + */ + public static function dotSet(array &$array, string $index, mixed $value): void + { + self::ensureValidWildcardPattern($index); + + $indexes = self::convertToArray($index); + + if ($indexes === []) { + return; + } + + self::setByDotPath($array, $indexes, $value); + } + + /** + * Removes a value by dot array syntax. + * + * @param array $array + */ + public static function dotUnset(array &$array, string $index): bool + { + self::ensureValidWildcardPattern($index, true); + + if ($index === '*') { + return self::clearByDotPath($array, []) > 0; + } + + $indexes = self::convertToArray($index); + + if ($indexes === []) { + return false; + } + + if (str_ends_with($index, '*')) { + return self::clearByDotPath($array, $indexes) > 0; + } + + return self::unsetByDotPath($array, $indexes) > 0; + } + + /** + * Gets only the specified keys using dot syntax. + * + * @param array|object $array + * @param list|string $indexes + * + * @return array + */ + public static function dotOnly(array|object $array, array|string $indexes): array + { + $indexes = is_string($indexes) ? [$indexes] : $indexes; + $result = []; + + foreach ($indexes as $index) { + self::ensureValidWildcardPattern($index, true); + + if ($index === '*') { + $result = [...$result, ...(is_object($array) ? self::toIterable($array) : $array)]; continue; } - if (! array_key_exists($currentIndex, $currentArray)) { - return false; + $segments = self::convertToArray($index); + if ($segments === []) { + continue; } - $currentArray = $currentArray[$currentIndex]; + self::projectByDotPath($array, $segments, $result); } - return true; + return $result; + } + + /** + * Gets all keys except the specified ones using dot syntax. + * + * @param array|object $array + * @param list|string $indexes + * + * @return array + */ + public static function dotExcept(array|object $array, array|string $indexes): array + { + $indexes = is_string($indexes) ? [$indexes] : $indexes; + + // Open only the root into an array view; nested values (including + // objects) are preserved until a path actually descends into them. + $result = self::entries($array); + + foreach ($indexes as $index) { + self::ensureValidWildcardPattern($index, true); + + if ($index === '*') { + $result = []; + + continue; + } + + $segments = self::convertToArray($index); + if ($segments === []) { + continue; + } + + if (str_ends_with($index, '*')) { + self::excludeChildrenByDotPath($result, $segments); + + continue; + } + + self::excludeByDotPath($result, $segments); + } + + return $result; } /** @@ -202,13 +349,16 @@ public static function groupBy(array $array, array $indexes, bool $includeEmpty /** * Recursively attach $row to the $indexes path of values found by - * `dot_array_search()`. + * dot syntax. * * @used-by groupBy() + * + * @param array|object $row + * @param list $indexes */ private static function arrayAttachIndexedValue( array $result, - array $row, + array|object $row, array $indexes, bool $includeEmpty, ): array { @@ -218,7 +368,7 @@ private static function arrayAttachIndexedValue( return $result; } - $value = dot_array_search($index, $row); + $value = self::dotSearch($index, $row); if (! is_scalar($value)) { $value = ''; @@ -315,4 +465,426 @@ public static function sortValuesByNatural(array &$array, $sortByIndex = null): return strnatcmp((string) $currentValue, (string) $nextValue); }); } + + /** + * Resolve a key against an array or object node, walking the access chain + * (Entity, ArrayAccess, public properties, magic `__isset`/`__get`) once. + * + * @param array|object $node + * + * @return array{bool, mixed} The pair [found, value]. + */ + private static function resolve(array|object $node, string $key): array + { + if (is_array($node)) { + return array_key_exists($key, $node) ? [true, $node[$key]] : [false, null]; + } + + $array = self::entityToArray($node); + + if ($array !== null) { + return array_key_exists($key, $array) ? [true, $array[$key]] : [false, null]; + } + + if ($node instanceof ArrayAccess && $node->offsetExists($key)) { + return [true, $node->offsetGet($key)]; + } + + $properties = get_object_vars($node); + + if (array_key_exists($key, $properties)) { + return [true, $properties[$key]]; + } + + return isset($node->{$key}) ? [true, $node->{$key}] : [false, null]; + } + + /** + * Whether keys can be resolved from this value, i.e. it is an array or an + * object that exposes a key surface: an expandable container, an + * `ArrayAccess`, or one relying on magic `__get`. Pure value-objects + * (e.g. `DateTimeImmutable`) are not navigable. + * + * Direct key lookup can support more object types than wildcard traversal: + * `ArrayAccess` and magic-only objects can resolve `user.id`, but cannot be + * enumerated for `user.*` unless they are also expandable. + */ + private static function isNavigable(mixed $value): bool + { + if (is_array($value)) { + return true; + } + + return is_object($value) + && (self::isExpandable($value) + || $value instanceof ArrayAccess + || method_exists($value, '__get')); + } + + /** + * Entries of an array or object node for wildcard traversal. + * + * @param array|object $node + * + * @return array + */ + private static function entries(array|object $node): array + { + return is_object($node) ? self::toIterable($node) : $node; + } + + /** + * @return array|null + */ + private static function entityToArray(object $data): ?array + { + if ($data instanceof Entity) { + return $data->toArray(); + } + + return null; + } + + /** + * Normalize an object to an array safe to iterate with foreach. + * + * Entities are converted via toArray() so internal properties like + * `_options` or `_cast` are not exposed. Other Traversable objects are + * converted to an array with their keys preserved; plain objects fall back + * to their public properties. + * + * @return array + */ + private static function toIterable(object $data): array + { + $array = self::entityToArray($data); + + if ($array !== null) { + return $array; + } + + if ($data instanceof Traversable) { + return iterator_to_array($data); + } + + return get_object_vars($data); + } + + /** + * Whether an object should be expanded into an array when building output. + * + * Only enumerable containers are expanded: entities, `stdClass`, other + * `Traversable` objects, and plain objects exposing public properties. + * Opaque objects with no enumerable key surface (value-objects such as + * `DateTimeImmutable`, magic-only or pure `ArrayAccess` objects) are + * preserved as-is, since they cannot be faithfully rebuilt as an array. + */ + private static function isExpandable(object $value): bool + { + return $value instanceof Entity + || $value instanceof stdClass + || $value instanceof Traversable + || get_object_vars($value) !== []; + } + + /** + * Ensure a value can be descended into for a partial exclusion/projection. + * + * Arrays pass through; expandable objects are converted to an array view + * in place (this is the only point where output structure is fabricated). + * Anything else (scalars, value-objects, magic-only or pure `ArrayAccess` + * objects) is left untouched and reported as non-descendable. + */ + private static function expandForDescent(mixed &$value): bool + { + if (is_array($value)) { + return true; + } + + if (is_object($value) && self::isExpandable($value)) { + $value = self::entries($value); + + return true; + } + + return false; + } + + /** + * Throws exception for invalid wildcard patterns. + */ + private static function ensureValidWildcardPattern(string $index, bool $allowTrailingWildcard = false): void + { + if ((! $allowTrailingWildcard && str_ends_with($index, '*')) || str_contains($index, '*.*')) { + throw new InvalidArgumentException( + 'You must set key right after "*". Invalid index: "' . $index . '"', + ); + } + } + + /** + * Set value recursively by dot path, including wildcard support. + * + * @param array $array + * @param list $indexes + */ + private static function setByDotPath(array &$array, array $indexes, mixed $value): void + { + if ($indexes === []) { + return; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + foreach ($array as &$item) { + if (! is_array($item)) { + continue; + } + + self::setByDotPath($item, $indexes, $value); + } + unset($item); + + return; + } + + if ($indexes === []) { + $array[$currentIndex] = $value; + + return; + } + + if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) { + $array[$currentIndex] = []; + } + + self::setByDotPath($array[$currentIndex], $indexes, $value); + } + + /** + * Unset value recursively by dot path, including wildcard support. + * + * @param array $array + * @param list $indexes + */ + private static function unsetByDotPath(array &$array, array $indexes): int + { + if ($indexes === []) { + return 0; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + $removed = 0; + + foreach ($array as &$item) { + if (! is_array($item)) { + continue; + } + + $removed += self::unsetByDotPath($item, $indexes); + } + unset($item); + + return $removed; + } + + if ($indexes === []) { + if (! array_key_exists($currentIndex, $array)) { + return 0; + } + + unset($array[$currentIndex]); + + return 1; + } + + if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) { + return 0; + } + + return self::unsetByDotPath($array[$currentIndex], $indexes); + } + + /** + * Clears all children under the specified path. + * + * @param array $array + * @param list $indexes + */ + private static function clearByDotPath(array &$array, array $indexes): int + { + if ($indexes === []) { + $count = count($array); + $array = []; + + return $count; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + $cleared = 0; + + foreach ($array as &$item) { + if (! is_array($item)) { + continue; + } + + $cleared += self::clearByDotPath($item, $indexes); + } + unset($item); + + return $cleared; + } + + if (! array_key_exists($currentIndex, $array) || ! is_array($array[$currentIndex])) { + return 0; + } + + return self::clearByDotPath($array[$currentIndex], $indexes); + } + + /** + * Removes a value by dot path for dotExcept(). Objects are expanded to an + * array view only when the path descends into them, so untouched branches + * keep their original values (including objects). + * + * @param array $array + * @param list $indexes + */ + private static function excludeByDotPath(array &$array, array $indexes): int + { + if ($indexes === []) { + return 0; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + $removed = 0; + + foreach ($array as &$item) { + if (self::expandForDescent($item)) { + $removed += self::excludeByDotPath($item, $indexes); + } + } + unset($item); + + return $removed; + } + + if ($indexes === []) { + if (! array_key_exists($currentIndex, $array)) { + return 0; + } + + unset($array[$currentIndex]); + + return 1; + } + + if (! array_key_exists($currentIndex, $array) || ! self::expandForDescent($array[$currentIndex])) { + return 0; + } + + return self::excludeByDotPath($array[$currentIndex], $indexes); + } + + /** + * Clears all children under the specified path for dotExcept(), expanding + * objects to an array view only along the descended path. + * + * @param array $array + * @param list $indexes + */ + private static function excludeChildrenByDotPath(array &$array, array $indexes): int + { + if ($indexes === []) { + $count = count($array); + $array = []; + + return $count; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + $cleared = 0; + + foreach ($array as &$item) { + if (self::expandForDescent($item)) { + $cleared += self::excludeChildrenByDotPath($item, $indexes); + } + } + unset($item); + + return $cleared; + } + + if (! array_key_exists($currentIndex, $array) || ! self::expandForDescent($array[$currentIndex])) { + return 0; + } + + return self::excludeChildrenByDotPath($array[$currentIndex], $indexes); + } + + /** + * Projects matching paths from source into result with preserved structure. + * + * @param array|object $source + * @param list $indexes + * @param list $prefix + * @param array $result + */ + private static function projectByDotPath( + array|object $source, + array $indexes, + array &$result, + array $prefix = [], + ): void { + if ($indexes === []) { + // The whole node was selected: preserve it as-is. Output structure + // is only fabricated for the projection skeleton above this leaf. + self::setByDotPath($result, $prefix, $source); + + return; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + foreach (self::entries($source) as $key => $value) { + if (! self::isNavigable($value)) { + if ($indexes === []) { + self::setByDotPath($result, [...$prefix, (string) $key], $value); + } + + continue; + } + + self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]); + } + + return; + } + + [$found, $value] = self::resolve($source, $currentIndex); + + if (! $found) { + return; + } + + if (! self::isNavigable($value)) { + if ($indexes === []) { + self::setByDotPath($result, [...$prefix, $currentIndex], $value); + } + + return; + } + + self::projectByDotPath($value, $indexes, $result, [...$prefix, $currentIndex]); + } } diff --git a/system/Helpers/array_helper.php b/system/Helpers/array_helper.php index 4d5d12488d78..be53ff544c82 100644 --- a/system/Helpers/array_helper.php +++ b/system/Helpers/array_helper.php @@ -20,14 +20,82 @@ * Searches an array through dot syntax. Supports * wildcard searches, like foo.*.bar * + * @param array|object $array + * * @return mixed */ - function dot_array_search(string $index, array $array) + function dot_array_search(string $index, array|object $array) { return ArrayHelper::dotSearch($index, $array); } } +if (! function_exists('dot_array_has')) { + /** + * Checks if an array key exists using dot syntax. + * + * @param array|object $array + */ + function dot_array_has(string $index, array|object $array): bool + { + return ArrayHelper::dotHas($index, $array); + } +} + +if (! function_exists('dot_array_set')) { + /** + * Sets an array value using dot syntax. + * + * @param array $array + */ + function dot_array_set(array &$array, string $index, mixed $value): void + { + ArrayHelper::dotSet($array, $index, $value); + } +} + +if (! function_exists('dot_array_unset')) { + /** + * Unsets an array value using dot syntax. + * + * @param array $array + */ + function dot_array_unset(array &$array, string $index): bool + { + return ArrayHelper::dotUnset($array, $index); + } +} + +if (! function_exists('dot_array_only')) { + /** + * Gets only the specified keys using dot syntax. + * + * @param array|object $array + * @param list|string $indexes + * + * @return array + */ + function dot_array_only(array|object $array, array|string $indexes): array + { + return ArrayHelper::dotOnly($array, $indexes); + } +} + +if (! function_exists('dot_array_except')) { + /** + * Gets all keys except the specified ones using dot syntax. + * + * @param array|object $array + * @param list|string $indexes + * + * @return array + */ + function dot_array_except(array|object $array, array|string $indexes): array + { + return ArrayHelper::dotExcept($array, $indexes); + } +} + if (! function_exists('array_deep_search')) { /** * Returns the value of an element at a key in an array of uncertain depth. diff --git a/system/Helpers/form_helper.php b/system/Helpers/form_helper.php index 6fa4777e2454..99f17a38517c 100644 --- a/system/Helpers/form_helper.php +++ b/system/Helpers/form_helper.php @@ -703,7 +703,7 @@ function validation_errors() // Check the session to see if any were // passed along from a redirect withErrors() request. - if ($errors !== null && (ENVIRONMENT === 'testing' || ! is_cli())) { + if ($errors !== null && (service('environment')->isTesting() || ! is_cli())) { return $errors; } diff --git a/system/Honeypot/Exceptions/HoneypotException.php b/system/Honeypot/Exceptions/HoneypotException.php index 5185f6d3c843..2a03ec2a8d5c 100644 --- a/system/Honeypot/Exceptions/HoneypotException.php +++ b/system/Honeypot/Exceptions/HoneypotException.php @@ -37,18 +37,6 @@ public static function forNoNameField() return new static(lang('Honeypot.noNameField')); } - /** - * Thrown when the hidden value of config is false. - * - * @return static - * - * @deprecated 4.6.4 Never used. - */ - public static function forNoHiddenValue() - { - return new static(lang('Honeypot.noHiddenValue')); - } - /** * Thrown when there are no data in the request of honeypot field. * diff --git a/system/I18n/TimeLegacy.php b/system/I18n/TimeLegacy.php index 034cab0dadcf..4b5b0eee7253 100644 --- a/system/I18n/TimeLegacy.php +++ b/system/I18n/TimeLegacy.php @@ -42,7 +42,7 @@ * * @phpstan-consistent-constructor * - * @deprecated Use Time instead. + * @deprecated 4.3.0 Use Time instead. * @see \CodeIgniter\I18n\TimeLegacyTest */ class TimeLegacy extends DateTime diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 04499668f5ed..c4b08b2261f8 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -292,7 +292,7 @@ public static function createFromInstance(DateTimeInterface $dateTime, ?string $ * * @throws Exception * - * @deprecated Use createFromInstance() instead + * @deprecated 4.3.0 Use createFromInstance() instead * * @codeCoverageIgnore */ @@ -966,14 +966,7 @@ public function equals($testTime, ?string $timezone = null): bool */ public function sameAs($testTime, ?string $timezone = null): bool { - if ($testTime instanceof DateTimeInterface) { - $testTime = $testTime->format('Y-m-d H:i:s.u O'); - } elseif (is_string($testTime)) { - $timezone = in_array($timezone, [null, '', '0'], true) ? $this->timezone : $timezone; - $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); - $testTime = new DateTime($testTime, $timezone); - $testTime = $testTime->format('Y-m-d H:i:s.u O'); - } + $testTime = $this->normalizeTime($testTime, $timezone)->format('Y-m-d H:i:s.u O'); $ourTime = $this->format('Y-m-d H:i:s.u O'); @@ -1024,6 +1017,63 @@ public function isAfter($testTime, ?string $timezone = null): bool return $ourTimestamp > $testTimestamp; } + /** + * Determines if the current instance's time is between two others. + * + * If $start is after $end, the arguments are swapped. + * + * @param string|null $timezone Used only when $start or $end is a string. + * + * @throws Exception + */ + public function between(DateTimeInterface|string $start, DateTimeInterface|string $end, bool $inclusive = true, ?string $timezone = null): bool + { + $start = $this->normalizeTime($start, $timezone); + $end = $this->normalizeTime($end, $timezone); + + if ($start->isAfter($end)) { + [$start, $end] = [$end, $start]; + } + + if ($inclusive) { + return ! $this->isBefore($start) && ! $this->isAfter($end); + } + + return $this->isAfter($start) && $this->isBefore($end); + } + + /** + * Returns the earlier of the current instance and the provided time. + * + * If null is provided, compares against now in the current timezone. + * + * @param string|null $timezone Used only when $time is a string or null. + * + * @throws Exception + */ + public function min(DateTimeInterface|string|null $time = null, ?string $timezone = null): static + { + $time = $this->normalizeTime($time, $timezone); + + return $this->isAfter($time) ? $time : $this; + } + + /** + * Returns the later of the current instance and the provided time. + * + * If null is provided, compares against now in the current timezone. + * + * @param string|null $timezone Used only when $time is a string or null. + * + * @throws Exception + */ + public function max(DateTimeInterface|string|null $time = null, ?string $timezone = null): static + { + $time = $this->normalizeTime($time, $timezone); + + return $this->isBefore($time) ? $time : $this; + } + /** * Determines if the current instance's time is in the past. * @@ -1114,17 +1164,10 @@ public function humanize() */ public function difference($testTime, ?string $timezone = null) { - if (is_string($testTime)) { - $timezone = ($timezone !== null) ? new DateTimeZone($timezone) : $this->timezone; - $testTime = new DateTime($testTime, $timezone); - } elseif ($testTime instanceof static) { - $testTime = $testTime->toDateTime(); - } - - assert($testTime instanceof DateTime); + $testTime = $this->normalizeTime($testTime, $timezone)->toDateTime(); if ($this->timezone->getOffset($this) !== $testTime->getTimezone()->getOffset($this)) { - $testTime = $this->getUTCObject($testTime, $timezone); + $testTime = $this->getUTCObject($testTime); $ourTime = $this->getUTCObject($this); } else { $ourTime = $this->toDateTime(); @@ -1157,12 +1200,36 @@ public function getUTCObject($time, ?string $timezone = null) } if ($time instanceof DateTime || $time instanceof DateTimeImmutable) { - $time = $time->setTimezone(new DateTimeZone('UTC')); + return $time->setTimezone(new DateTimeZone('UTC')); } return $time; } + /** + * Returns a Time instance normalized to the current locale. + * + * If $time is a string, it will be parsed using the provided timezone, + * or the current instance's timezone when omitted. If null is provided, + * the current time is used in the same timezone. + * + * @throws Exception + */ + private function normalizeTime(DateTimeInterface|string|null $time, ?string $timezone = null): static + { + if ($time instanceof DateTimeInterface) { + return static::createFromInstance($time, $this->locale); + } + + $timezone = in_array($timezone, [null, '', '0'], true) ? $this->timezone : $timezone; + + if ($time === null) { + return static::now($timezone, $this->locale); + } + + return new static($time, $timezone, $this->locale); + } + /** * Returns the IntlCalendar object used for this object, * taking into account the locale, date, etc. diff --git a/system/Images/Handlers/BaseHandler.php b/system/Images/Handlers/BaseHandler.php index 9301b1573c23..e40379fc98f1 100644 --- a/system/Images/Handlers/BaseHandler.php +++ b/system/Images/Handlers/BaseHandler.php @@ -115,6 +115,7 @@ abstract class BaseHandler implements ImageHandlerInterface protected $supportTransparency = [ IMAGETYPE_PNG, IMAGETYPE_WEBP, + IMAGETYPE_AVIF, ]; /** diff --git a/system/Images/Handlers/GDHandler.php b/system/Images/Handlers/GDHandler.php index 01c05384c744..2e91ecac89a0 100644 --- a/system/Images/Handlers/GDHandler.php +++ b/system/Images/Handlers/GDHandler.php @@ -178,7 +178,7 @@ protected function process(string $action) $dest = $create($this->width, $this->height); - // for png and webp we can actually preserve transparency + // for png, webp and avif we can actually preserve transparency if (in_array($this->image()->imageType, $this->supportTransparency, true)) { imagealphablending($dest, false); imagesavealpha($dest, true); @@ -222,7 +222,7 @@ public function save(?string $target = null, int $quality = 90): bool $this->ensureResource(); - // for png and webp we can actually preserve transparency + // for png, webp and avif we can actually preserve transparency if (in_array($this->image()->imageType, $this->supportTransparency, true)) { imagepalettetotruecolor($this->resource); imagealphablending($this->resource, false); @@ -270,6 +270,16 @@ public function save(?string $target = null, int $quality = 90): bool } break; + case IMAGETYPE_AVIF: + if (! function_exists('imageavif')) { + throw ImageException::forInvalidImageCreate(lang('Images.avifNotSupported')); + } + + if (! @imageavif($this->resource, $target, $quality)) { + throw ImageException::forSaveFailed(); + } + break; + default: throw ImageException::forInvalidImageCreate(); } @@ -361,6 +371,13 @@ protected function getImageResource(string $path, int $imageType) return imagecreatefromwebp($path); + case IMAGETYPE_AVIF: + if (! function_exists('imagecreatefromavif')) { + throw ImageException::forInvalidImageCreate(lang('Images.avifNotSupported')); + } + + return imagecreatefromavif($path); + default: throw ImageException::forInvalidImageCreate('Ima'); } diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index 1472936d071b..8bf5f6a7a18f 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -21,6 +21,7 @@ use ImagickException; use ImagickPixel; use ImagickPixelException; +use RuntimeException; /** * Image handler for Imagick extension. @@ -46,7 +47,10 @@ public function __construct($config = null) parent::__construct($config); if (! extension_loaded('imagick')) { - throw ImageException::forMissingExtension('IMAGICK'); // @codeCoverageIgnore + throw new RuntimeException(sprintf( + 'The "%s" handler requires the "imagick" PHP extension.', + static::class, + )); } } @@ -302,8 +306,38 @@ protected function supportedFormatCheck() return; } - if ($this->image()->imageType === IMAGETYPE_WEBP && ! in_array('WEBP', Imagick::queryFormats(), true)) { - throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); + $supported = Imagick::queryFormats(); + + switch ($this->image()->imageType) { + case IMAGETYPE_GIF: + if (! in_array('GIF', $supported, true)) { + throw ImageException::forInvalidImageCreate(lang('Images.gifNotSupported')); + } + break; + + case IMAGETYPE_JPEG: + if (! in_array('JPEG', $supported, true)) { + throw ImageException::forInvalidImageCreate(lang('Images.jpgNotSupported')); + } + break; + + case IMAGETYPE_PNG: + if (! in_array('PNG', $supported, true)) { + throw ImageException::forInvalidImageCreate(lang('Images.pngNotSupported')); + } + break; + + case IMAGETYPE_WEBP: + if (! in_array('WEBP', $supported, true)) { + throw ImageException::forInvalidImageCreate(lang('Images.webpNotSupported')); + } + break; + + case IMAGETYPE_AVIF: + if (! in_array('AVIF', $supported, true)) { + throw ImageException::forInvalidImageCreate(lang('Images.avifNotSupported')); + } + break; } } diff --git a/system/Images/Image.php b/system/Images/Image.php index 0634405c6ec0..e7f5f3a86912 100644 --- a/system/Images/Image.php +++ b/system/Images/Image.php @@ -113,6 +113,7 @@ public function getProperties(bool $return = false) IMAGETYPE_JPEG => 'jpeg', IMAGETYPE_PNG => 'png', IMAGETYPE_WEBP => 'webp', + IMAGETYPE_AVIF => 'avif', ]; $mime = 'image/' . ($types[$vals[2]] ?? 'jpg'); diff --git a/system/Input/InputData.php b/system/Input/InputData.php new file mode 100644 index 000000000000..cae88b10b03b --- /dev/null +++ b/system/Input/InputData.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +/** + * @see \CodeIgniter\Input\InputDataTest + */ +class InputData +{ + /** + * @param array $data + */ + public function __construct(private readonly array $data) + { + } + + /** + * Returns a single input value by name, or the default value if the field + * is not present. + * + * Supports dot-array syntax for nested input data. + */ + public function get(string $key, mixed $default = null): mixed + { + helper('array'); + + if (! dot_array_has($key, $this->data)) { + return $default; + } + + return dot_array_search($key, $this->data); + } + + /** + * Returns true when the named field exists, even if its value is null. + * + * Supports dot-array syntax for nested input data. + */ + public function has(string $key): bool + { + helper('array'); + + return dot_array_has($key, $this->data); + } + + /** + * Returns an input field as a string. + * + * Supports dot-array syntax for nested input data. + */ + public function string(string $key, ?string $default = null): ?string + { + $value = $this->get($key, $default); + + if ($value === null || is_string($value)) { + return $value; + } + + return $this->invalidValue($key, 'string', $default); + } + + /** + * Returns an input field as an integer. + * + * Supports dot-array syntax for nested input data. + */ + public function integer(string $key, ?int $default = null): ?int + { + $value = $this->get($key, $default); + + if ($value === null || is_int($value)) { + return $value; + } + + if (is_string($value)) { + $integer = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + + if ($integer !== null) { + return $integer; + } + } + + return $this->invalidValue($key, 'integer', $default); + } + + /** + * Returns an input field as a float. + * + * Supports dot-array syntax for nested input data. + */ + public function float(string $key, ?float $default = null): ?float + { + $value = $this->get($key, $default); + + if ($value === null || is_float($value)) { + return $value; + } + + if (is_int($value)) { + return (float) $value; + } + + if (is_string($value)) { + $float = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + + if ($float !== null) { + return $float; + } + } + + return $this->invalidValue($key, 'float', $default); + } + + /** + * Returns an input field as a boolean. + * + * Supports dot-array syntax for nested input data. + */ + public function boolean(string $key, ?bool $default = null): ?bool + { + $value = $this->get($key, $default); + + if ($value === null || is_bool($value)) { + return $value; + } + + if (is_int($value) || is_string($value)) { + $boolean = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($boolean !== null) { + return $boolean; + } + } + + return $this->invalidValue($key, 'boolean', $default); + } + + /** + * Returns an input field as an array. + * + * Supports dot-array syntax for nested input data. + * + * @param array|null $default + * + * @return array|null + */ + public function array(string $key, ?array $default = null): ?array + { + $value = $this->get($key, $default); + + if ($value === null || is_array($value)) { + return $value; + } + + return $this->invalidValue($key, 'array', $default); + } + + protected function invalidValue(string $key, string $type, mixed $default): mixed + { + return $default; + } +} diff --git a/system/Input/InputDataFactory.php b/system/Input/InputDataFactory.php new file mode 100644 index 000000000000..ae91d9c9511d --- /dev/null +++ b/system/Input/InputDataFactory.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +/** + * @see \CodeIgniter\Input\InputDataFactoryTest + */ +class InputDataFactory +{ + /** + * @param array $data + */ + public function create(array $data): InputData + { + return new InputData($data); + } + + /** + * @param array $data + */ + public function createValidated(array $data): ValidatedInput + { + return new ValidatedInput($data); + } +} diff --git a/system/Input/ValidatedInput.php b/system/Input/ValidatedInput.php new file mode 100644 index 000000000000..ddcd23e1b203 --- /dev/null +++ b/system/Input/ValidatedInput.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use DateTimeZone; +use Exception; +use ReflectionEnum; +use UnitEnum; + +/** + * Represents validated input data. + * + * This class is stricter than InputData: missing values may use defaults and + * null values remain null, but invalid present values throw. + * + * @see \CodeIgniter\Input\ValidatedInputTest + */ +class ValidatedInput extends InputData +{ + /** + * Returns a validated field as a Time instance. + * + * Supports dot-array syntax for nested validated data. + * + * @throws InvalidArgumentException + */ + public function date( + string $key, + ?string $format = null, + DateTimeZone|string|null $timezone = null, + ?Time $default = null, + ): ?Time { + if (! $this->has($key)) { + return $default; + } + + $value = $this->get($key); + + if ($value === null) { + return null; + } + + if (! is_string($value) || $value === '') { + $this->invalidValue($key, 'date', null); + } + + try { + if ($format === null) { + return Time::parse($value, $timezone); + } + + return Time::createFromFormat($format, $value, $timezone); + } catch (Exception) { + $this->invalidValue($key, 'date', null); + } + } + + /** + * Returns a validated field as an enum instance. + * + * Supports dot-array syntax for nested validated data. + * + * @template TEnum of UnitEnum + * + * @param class-string $enumClass + * @param TEnum|null $default + * + * @return TEnum|null + * + * @throws InvalidArgumentException + */ + public function enum(string $key, string $enumClass, ?UnitEnum $default = null): ?UnitEnum + { + if (! enum_exists($enumClass)) { + throw new InvalidArgumentException('The "' . $enumClass . '" class is not a valid enum.'); + } + + if ($default instanceof UnitEnum && ! $default instanceof $enumClass) { + $this->invalidValue($key, $enumClass, $default); + } + + $value = $this->get($key, $default); + + if ($value === null) { + return null; + } + + if ($value instanceof UnitEnum) { + if ($value instanceof $enumClass) { + return $value; + } + + $this->invalidValue($key, $enumClass, $default); + } + + $reflection = new ReflectionEnum($enumClass); + + if ($reflection->isBacked()) { + return $this->backedEnum($key, $enumClass, $reflection, $value); + } + + if (is_string($value)) { + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + } + + $this->invalidValue($key, $enumClass, $default); + } + + private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum + { + $backingType = $reflection->getBackingType()?->getName(); + + if ($backingType === 'int') { + if (is_string($value)) { + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } + + if (! is_int($value)) { + $this->invalidValue($key, $enumClass, null); + } + } elseif (! is_int($value) && ! is_string($value)) { + $this->invalidValue($key, $enumClass, null); + } + + if ($backingType === 'string') { + $value = (string) $value; + } + + $enum = $enumClass::tryFrom($value); + + if ($enum === null) { + $this->invalidValue($key, $enumClass, null); + } + + return $enum; + } + + protected function invalidValue(string $key, string $type, mixed $default): never + { + throw new InvalidArgumentException( + sprintf('The validated "%s" value cannot be read as %s.', $key, $type), + ); + } +} diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 01e60c402955..def36f4331f7 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -15,6 +15,7 @@ return [ 'altCommandPlural' => 'Did you mean one of these?', 'altCommandSingular' => 'Did you mean this?', + 'commandAlias' => '[alias of {0}]', 'commandNotFound' => 'Command "{0}" not found.', 'generator' => [ 'cancelOperation' => 'Operation has been cancelled.', @@ -26,6 +27,7 @@ 'default' => 'Class name', 'entity' => 'Entity class name', 'filter' => 'Filter class name', + 'request' => 'FormRequest class name', 'migration' => 'Migration class name', 'model' => 'Model class name', 'seeder' => 'Seeder class name', @@ -47,13 +49,15 @@ 'cell' => 'Cell view name', ], ], - 'helpArguments' => 'Arguments:', - 'helpDescription' => 'Description:', - 'helpOptions' => 'Options:', - 'helpUsage' => 'Usage:', - 'invalidColor' => 'Invalid "{0}" color: "{1}".', - 'namespaceNotDefined' => 'Namespace "{0}" is not defined.', - 'signals' => [ + 'helpAliases' => 'Aliases:', + 'helpArguments' => 'Arguments:', + 'helpAvailableCommands' => 'Available commands:', + 'helpDescription' => 'Description:', + 'helpOptions' => 'Options:', + '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/system/Language/en/Cache.php b/system/Language/en/Cache.php index 63512cd525d5..4db6dd7bcf7a 100644 --- a/system/Language/en/Cache.php +++ b/system/Language/en/Cache.php @@ -13,9 +13,10 @@ // Cache language settings return [ - 'unableToWrite' => 'Cache unable to write to "{0}".', - 'invalidHandler' => 'Cache driver "{0}" is not a valid cache handler.', - 'invalidHandlers' => 'Cache config must have an array of $validHandlers.', - 'noBackup' => 'Cache config must have a handler and backupHandler set.', - 'handlerNotFound' => 'Cache config has an invalid handler or backup handler specified.', + 'unableToWrite' => 'Cache unable to write to "{0}".', + 'invalidHandler' => 'Cache driver "{0}" is not a valid cache handler.', + 'invalidHandlers' => 'Cache config must have an array of $validHandlers.', + 'noBackup' => 'Cache config must have a handler and backupHandler set.', + 'handlerNotFound' => 'Cache config has an invalid handler or backup handler specified.', + 'unsupportedLockStore' => 'The cache handler cannot provide a lock store with the current runtime client.', ]; diff --git a/system/Language/en/Cast.php b/system/Language/en/Cast.php index 63d9fba01b7c..d4762634ca81 100644 --- a/system/Language/en/Cast.php +++ b/system/Language/en/Cast.php @@ -13,18 +13,19 @@ // 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.', - 'jsonErrorDepth' => 'Maximum stack depth exceeded.', - 'jsonErrorStateMismatch' => 'Underflow or the modes mismatch.', - 'jsonErrorSyntax' => 'Syntax error, malformed JSON.', - 'jsonErrorUnknown' => 'Unknown error.', - 'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded.', + '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.', + 'jsonErrorDepth' => 'Maximum stack depth exceeded.', + 'jsonErrorStateMismatch' => 'Underflow or the modes mismatch.', + 'jsonErrorSyntax' => 'Syntax error, malformed JSON.', + 'jsonErrorUnknown' => 'Unknown error.', + 'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded.', + 'invalidFloatRoundingMode' => 'Invalid rounding mode "{0}" for float casting.', ]; diff --git a/system/Language/en/Commands.php b/system/Language/en/Commands.php new file mode 100644 index 000000000000..142138338d16 --- /dev/null +++ b/system/Language/en/Commands.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// Commands language settings +return [ + 'aliasClashesWithAlias' => 'Command alias "{0}" of the "{1}" command is already used as an alias of the "{2}" command.', + 'aliasClashesWithCommandName' => 'Command alias "{0}" of the "{1}" command clashes with an existing command of the same name.', + 'arrayArgumentInvalidDefault' => 'Array argument "{0}" must have an array default value or null.', + 'arrayArgumentCannotBeRequired' => 'Array argument "{0}" cannot be required.', + 'arrayOptionInvalidDefault' => 'Array option "--{0}" must have an array default value or null.', + 'arrayOptionMustRequireValue' => 'Array option "--{0}" must require a value.', + 'arrayOptionEmptyArrayDefault' => 'Array option "--{0}" cannot have an empty array as the default value.', + 'argumentAfterArrayArgument' => 'Argument "{0}" cannot be defined after array argument "{1}".', + 'commandAliasSameAsName' => 'Command alias "{0}" cannot be the same as the command name.', + 'duplicateArgument' => 'An argument with the name "{0}" is already defined.', + 'duplicateCommandAlias' => 'Command alias "{0}" is defined more than once.', + 'duplicateCommandName' => 'Warning: The "{0}" command is defined as both legacy ({1}) and modern ({2}). The legacy command will be executed. Please rename or remove one.', + 'duplicateOption' => 'An option with the name "--{0}" is already defined.', + 'duplicateShortcut' => 'Shortcut "-{0}" cannot be used for option "--{1}"; it is already assigned to option "--{2}".', + 'emptyCommandName' => 'Command name cannot be empty.', + 'emptyArgumentName' => 'Argument name cannot be empty.', + 'emptyOptionName' => 'Option name cannot be empty.', + 'emptyShortcutName' => 'Shortcut name cannot be empty.', + 'flagOptionPassedMultipleTimes' => 'Option "--{0}" is passed multiple times.', + 'invalidCommandAlias' => 'Command alias "{0}" is not valid.', + 'invalidCommandName' => 'Command name "{0}" is not valid.', + 'invalidArgumentName' => 'Argument name "{0}" is not valid.', + 'invalidOptionName' => 'Option name "--{0}" is not valid.', + 'invalidShortcutName' => 'Shortcut name "-{0}" is not valid.', + 'invalidShortcutNameLength' => 'Shortcut name "-{0}" must be a single character.', + 'missingCommandAttribute' => 'Command class "{0}" is missing the {1} attribute.', + 'missingRequiredArguments' => 'Command "{0}" is missing the following required {1, plural, =1{argument} other{arguments}}: {2}.', + 'negatableOptionNegationExists' => 'Negatable option "--{0}" cannot be defined because its negation "--no-{0}" already exists as an option.', + 'negatableOptionNoValue' => 'Negatable option "--{0}" does not accept a value.', + 'negatableOptionMustNotAcceptValue' => 'Negatable option "--{0}" cannot be defined to accept a value.', + 'negatableOptionCannotBeArray' => 'Negatable option "--{0}" cannot be defined as an array.', + 'negatableOptionInvalidDefault' => 'Negatable option "--{0}" must have a boolean default value.', + 'negatableOptionPassedMultipleTimes' => 'Negatable option "--{0}" is passed multiple times.', + 'negatableOptionWithNegation' => 'Option "--{0}" and its negation "--{1}" cannot be used together.', + 'negatedOptionNoValue' => 'Negated option "--{0}" does not accept a value.', + 'negatedOptionPassedMultipleTimes' => 'Negated option "--{0}" is passed multiple times.', + 'noArgumentsExpected' => 'No arguments expected for "{0}" command. Received: "{1}".', + 'nonArrayArgumentWithArrayDefault' => 'Argument "{0}" does not accept an array default value.', + 'nonArrayOptionWithArrayValue' => 'Option "--{0}" does not accept an array value.', + 'notAvailable' => 'Command "{0}" is not available in the current environment.', + 'optionClashesWithExistingNegation' => 'Option "--{0}" clashes with the negation of negatable option "--{1}".', + 'optionNoValueAndNoDefault' => 'Option "--{0}" does not accept a value and cannot have a default value.', + 'optionNotAcceptingValue' => 'Option "--{0}" does not accept a value.', + 'optionalArgumentNoDefault' => 'Argument "{0}" is optional and must have a default value.', + 'optionRequiresStringDefaultValue' => 'Option "--{0}" requires a string default value.', + 'optionRequiresValue' => 'Option "--{0}" requires a value to be provided.', + 'requiredArgumentNoDefault' => 'Argument "{0}" is required and must not have a default value.', + 'requiredArgumentAfterOptionalArgument' => 'Required argument "{0}" cannot be defined after optional argument "{1}".', + 'reservedArgumentName' => 'Argument name "extra_arguments" is reserved and cannot be used.', + 'reservedOptionName' => 'Option name "--extra_options" is reserved and cannot be used.', + 'tooManyArguments' => '{1, plural, =1{One unexpected argument was} other{Multiple unexpected arguments were}} provided to "{0}" command: "{2}".', + 'unknownOptions' => 'The following {0, plural, =1{option} other{options}} {0, plural, =1{is} other{are}} unknown in the "{1}" command: {2}.', +]; diff --git a/system/Language/en/Core.php b/system/Language/en/Core.php index c01d43a9a2c0..f6f98693bcaa 100644 --- a/system/Language/en/Core.php +++ b/system/Language/en/Core.php @@ -18,6 +18,5 @@ 'invalidFile' => 'Invalid file: "{0}"', 'invalidDirectory' => 'Directory does not exist: "{0}"', 'invalidPhpVersion' => 'Your PHP version must be {0} or higher to run CodeIgniter. Current version: {1}', - 'missingExtension' => 'The framework needs the following extension(s) installed and loaded: "{0}".', 'noHandlers' => '"{0}" must provide at least one Handler.', ]; diff --git a/system/Language/en/Database.php b/system/Language/en/Database.php index c5a5ddc8548a..94c8d3a9a7fc 100644 --- a/system/Language/en/Database.php +++ b/system/Language/en/Database.php @@ -16,6 +16,7 @@ 'invalidEvent' => '"{0}" is not a valid Model Event callback.', 'invalidArgument' => 'You must provide a valid "{0}".', 'invalidAllowedFields' => 'Allowed fields must be specified for model: "{0}"', + 'disallowedFields' => 'Fields are not allowed for model "{0}": {1}', 'emptyDataset' => 'There is no data to {0}.', 'emptyPrimaryKey' => 'There is no primary key defined when trying to make {0}.', 'failGetFieldData' => 'Failed to get field data from database.', diff --git a/system/Language/en/Email.php b/system/Language/en/Email.php index 44d4c03cae3e..03073ad9af81 100644 --- a/system/Language/en/Email.php +++ b/system/Language/en/Email.php @@ -34,6 +34,7 @@ 'SMTPAuthPassword' => 'Failed to authenticate password. Error: {0}', 'SMTPDataFailure' => 'Unable to send data: {0}', 'exitStatus' => 'Exit status code: {0}', - // @deprecated + + // @deprecated v4.7.0 'failedSMTPLogin' => 'Failed to send AUTH LOGIN command. Error: {0}', ]; diff --git a/system/Language/en/HTTP.php b/system/Language/en/HTTP.php index 854a897c017b..7321253e7196 100644 --- a/system/Language/en/HTTP.php +++ b/system/Language/en/HTTP.php @@ -37,7 +37,6 @@ 'cannotSetBinary' => 'When setting filepath cannot set binary.', 'cannotSetFilepath' => 'When setting binary cannot set filepath: "{0}"', 'notFoundDownloadSource' => 'Not found download body source.', - 'cannotSetCache' => 'It does not support caching for downloading.', 'cannotSetStatusCode' => 'It does not support change status code for downloading. code: {0}, reason: {1}', // Response @@ -58,10 +57,6 @@ 'methodNotFound' => 'Controller method is not found: "{0}"', 'localeNotSupported' => 'Locale is not supported: {0}', - // CSRF - // @deprecated use 'Security.disallowedAction' - 'disallowedAction' => 'The action you requested is not allowed.', - // Uploaded file moving 'alreadyMoved' => 'The uploaded file has already been moved.', 'invalidFile' => 'The original file is not a valid file.', @@ -76,8 +71,4 @@ 'uploadErrNoTmpDir' => 'File could not be uploaded: missing temporary directory.', 'uploadErrExtension' => 'File upload was stopped by a PHP extension.', 'uploadErrUnknown' => 'The file "%s" was not uploaded due to an unknown error.', - - // SameSite setting - // @deprecated - 'invalidSameSiteSetting' => 'The SameSite setting must be None, Lax, Strict, or a blank string. Given: {0}', ]; diff --git a/system/Language/en/Images.php b/system/Language/en/Images.php index 2af9e0a2d1d3..2302cfb89018 100644 --- a/system/Language/en/Images.php +++ b/system/Language/en/Images.php @@ -20,6 +20,7 @@ 'jpgNotSupported' => 'JPG images are not supported.', 'pngNotSupported' => 'PNG images are not supported.', 'webpNotSupported' => 'WEBP images are not supported.', + 'avifNotSupported' => 'AVIF images are not supported.', 'fileNotSupported' => 'The supplied file is not a supported image type.', 'unsupportedImageCreate' => 'Your server does not support the required functionality to process this type of image.', 'jpgOrPngRequired' => 'The image resize protocol specified in your preferences only works with JPEG or PNG image types.', @@ -33,6 +34,6 @@ 'invalidDirection' => 'Flip direction can be only "vertical" or "horizontal". Given: "{0}"', 'exifNotSupported' => 'Reading EXIF data is not supported by this PHP installation.', - // @deprecated + // @deprecated 4.7.0 'libPathInvalid' => 'The path to your image library is not correct. Please set the correct path in your image preferences. "{0}"', ]; diff --git a/system/Language/en/Lock.php b/system/Language/en/Lock.php new file mode 100644 index 000000000000..ce87c6e9d72d --- /dev/null +++ b/system/Language/en/Lock.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// Lock language settings +return [ + 'unsupportedStore' => 'The cache handler "{0}" does not support locks.', +]; diff --git a/system/Language/en/Security.php b/system/Language/en/Security.php index fd906e378a29..345aaa7ce0c2 100644 --- a/system/Language/en/Security.php +++ b/system/Language/en/Security.php @@ -15,7 +15,4 @@ return [ 'disallowedAction' => 'The action you requested is not allowed.', 'insecureCookie' => 'Attempted to send a secure cookie over a non-secure connection.', - - // @deprecated - 'invalidSameSite' => 'The SameSite value must be None, Lax, Strict, or a blank string. Given: "{0}"', ]; diff --git a/system/Language/en/Session.php b/system/Language/en/Session.php index 53d8bba95789..d067410462c0 100644 --- a/system/Language/en/Session.php +++ b/system/Language/en/Session.php @@ -18,7 +18,4 @@ 'writeProtectedSavePath' => 'Session: Configured save path "{0}" is not writable by the PHP process.', 'emptySavePath' => 'Session: No save path configured.', 'invalidSavePathFormat' => 'Session: Invalid Redis save path format: "{0}"', - - // @deprecated - 'invalidSameSiteSetting' => 'Session: The SameSite setting must be None, Lax, Strict, or a blank string. Given: "{0}"', ]; diff --git a/system/Lock/Exceptions/LockException.php b/system/Lock/Exceptions/LockException.php new file mode 100644 index 000000000000..8f6785040a66 --- /dev/null +++ b/system/Lock/Exceptions/LockException.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +class LockException extends FrameworkException +{ + public static function forUnsupportedStore(string $class): self + { + return new self(lang('Lock.unsupportedStore', [$class])); + } +} diff --git a/system/Lock/Lock.php b/system/Lock/Lock.php new file mode 100644 index 000000000000..d6d7e764bccf --- /dev/null +++ b/system/Lock/Lock.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\Lock; + +use Closure; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Exceptions\InvalidArgumentException; + +final readonly class Lock implements LockInterface +{ + private const BLOCK_RETRY_MICROSECONDS = 100_000; + + public function __construct( + private LockStoreInterface $store, + private string $key, + private int $ttl, + private string $owner, + ) { + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be a positive integer.'); + } + + if ($owner === '') { + throw new InvalidArgumentException('Lock owner cannot be empty.'); + } + } + + public function acquire(): bool + { + return $this->store->acquireLock($this->key, $this->owner, $this->ttl); + } + + public function block(int $seconds): bool + { + if ($seconds < 1) { + return $this->acquire(); + } + + $expiresAt = microtime(true) + $seconds; + + do { + if ($this->acquire()) { + return true; + } + + usleep(self::BLOCK_RETRY_MICROSECONDS); + } while (microtime(true) < $expiresAt); + + return false; + } + + /** + * @param Closure(): mixed $callback + */ + public function run(Closure $callback, int $waitSeconds = 0): mixed + { + $acquired = $waitSeconds > 0 ? $this->block($waitSeconds) : $this->acquire(); + + if (! $acquired) { + return false; + } + + try { + return $callback(); + } finally { + $this->release(); + } + } + + public function release(): bool + { + return $this->store->releaseLock($this->key, $this->owner); + } + + public function forceRelease(): bool + { + return $this->store->forceReleaseLock($this->key); + } + + public function refresh(?int $ttl = null): bool + { + $ttl ??= $this->ttl; + + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be a positive integer.'); + } + + return $this->store->refreshLock($this->key, $this->owner, $ttl); + } + + public function isAcquired(): bool + { + return $this->store->getLockOwner($this->key) === $this->owner; + } + + public function owner(): string + { + return $this->owner; + } +} diff --git a/system/Lock/LockInterface.php b/system/Lock/LockInterface.php new file mode 100644 index 000000000000..9aa45f8fb472 --- /dev/null +++ b/system/Lock/LockInterface.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\Lock; + +use Closure; + +interface LockInterface +{ + /** + * Attempts to acquire the lock immediately. + */ + public function acquire(): bool; + + /** + * Attempts to acquire the lock, waiting up to the given number of seconds. + */ + public function block(int $seconds): bool; + + /** + * Runs the callback while the lock is held. + * + * @param Closure(): mixed $callback + */ + public function run(Closure $callback, int $waitSeconds = 0): mixed; + + /** + * Releases the lock only if this instance still owns it. + */ + public function release(): bool; + + /** + * Releases the lock without checking ownership. + */ + public function forceRelease(): bool; + + /** + * Extends the lock TTL only if this instance still owns it. + */ + public function refresh(?int $ttl = null): bool; + + /** + * Checks whether this instance still owns the lock. + */ + public function isAcquired(): bool; + + /** + * Returns this instance's owner token. + */ + public function owner(): string; +} diff --git a/system/Lock/LockManager.php b/system/Lock/LockManager.php new file mode 100644 index 000000000000..efa8ca82b474 --- /dev/null +++ b/system/Lock/LockManager.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock; + +use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\Exceptions\CacheException; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Lock\Exceptions\LockException; + +final readonly class LockManager +{ + private const KEY_PREFIX = 'lock_'; + + private LockStoreInterface $store; + + /** + * @param CacheInterface&LockStoreProviderInterface $cache Cache handler that supports lock stores. + * + * @throws LockException When the cache handler does not support locks. + */ + public function __construct(CacheInterface $cache) + { + if (! $cache instanceof LockStoreProviderInterface) { + throw LockException::forUnsupportedStore($cache::class); + } + + try { + $this->store = $cache->lockStore(); + } catch (CacheException) { + throw LockException::forUnsupportedStore($cache::class); + } + } + + public function create(string $name, int $ttl = 300, ?string $owner = null): LockInterface + { + if ($name === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + + return new Lock($this->store, $this->key($name), $ttl, $owner ?? bin2hex(random_bytes(16))); + } + + public function restore(string $name, string $owner, int $ttl = 300): LockInterface + { + return $this->create($name, $ttl, $owner); + } + + private function key(string $name): string + { + return self::KEY_PREFIX . hash('xxh128', $name); + } +} diff --git a/system/Log/Handlers/BaseHandler.php b/system/Log/Handlers/BaseHandler.php index 2b82f0f49225..20dceb8144c7 100644 --- a/system/Log/Handlers/BaseHandler.php +++ b/system/Log/Handlers/BaseHandler.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Log\Handlers; +use JsonException; + /** * Base class for logging */ @@ -58,4 +60,20 @@ public function setDateFormat(string $format): HandlerInterface return $this; } + + /** + * Encodes the context array as a JSON string. + * Returns the JSON string on success, or a descriptive error string if + * encoding fails (e.g. context contains a resource or invalid UTF-8). + * + * @param array $context + */ + protected function encodeContext(array $context): string + { + try { + return json_encode($context, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + return '[context: JSON encoding failed - ' . $e->getMessage() . ']'; + } + } } diff --git a/system/Log/Handlers/ChromeLoggerHandler.php b/system/Log/Handlers/ChromeLoggerHandler.php index 8d763399b513..5afdac599d9d 100644 --- a/system/Log/Handlers/ChromeLoggerHandler.php +++ b/system/Log/Handlers/ChromeLoggerHandler.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Log\Handlers; use CodeIgniter\HTTP\ResponseInterface; +use JsonException; /** * Allows for logging items to the Chrome console for debugging. @@ -99,10 +100,11 @@ public function __construct(array $config = []) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { $message = $this->format($message); @@ -121,7 +123,9 @@ public function handle($level, $message): bool $type = $this->levels[$level]; } - $this->json['rows'][] = [[$message], $backtraceMessage, $type]; + $logArgs = $context !== [] ? [$message, $context] : [$message]; + + $this->json['rows'][] = [$logArgs, $backtraceMessage, $type]; $this->sendLogs(); @@ -162,8 +166,17 @@ public function sendLogs(?ResponseInterface &$response = null) $response = service('response', null, true); } + try { + $encoded = json_encode($this->json, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $encoded = json_encode($this->json, JSON_PARTIAL_OUTPUT_ON_ERROR); + if ($encoded === false) { + return; + } + } + $data = base64_encode( - mb_convert_encoding(json_encode($this->json), 'UTF-8', mb_list_encodings()), + mb_convert_encoding($encoded, 'UTF-8', mb_list_encodings()), ); $response->setHeader($this->header, $data); diff --git a/system/Log/Handlers/ErrorlogHandler.php b/system/Log/Handlers/ErrorlogHandler.php index a7e820419fee..52f9add8cb5d 100644 --- a/system/Log/Handlers/ErrorlogHandler.php +++ b/system/Log/Handlers/ErrorlogHandler.php @@ -66,11 +66,16 @@ public function __construct(array $config = []) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { + if ($context !== []) { + $message .= ' ' . $this->encodeContext($context); + } + $message = strtoupper($level) . ' --> ' . $message . "\n"; return $this->errorLog($message, $this->messageType); diff --git a/system/Log/Handlers/FileHandler.php b/system/Log/Handlers/FileHandler.php index 801200da85c2..99bdc2b20113 100644 --- a/system/Log/Handlers/FileHandler.php +++ b/system/Log/Handlers/FileHandler.php @@ -69,12 +69,13 @@ public function __construct(array $config = []) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context * * @throws Exception */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { $filepath = $this->path . 'log-' . date('Y-m-d') . '.' . $this->fileExtension; @@ -104,6 +105,10 @@ public function handle($level, $message): bool $date = date($this->dateFormat); } + if ($context !== []) { + $message .= ' ' . $this->encodeContext($context); + } + $msg .= strtoupper($level) . ' - ' . $date . ' --> ' . $message . "\n"; flock($fp, LOCK_EX); diff --git a/system/Log/Handlers/HandlerInterface.php b/system/Log/Handlers/HandlerInterface.php index 40a9958c714a..b0f767fd2805 100644 --- a/system/Log/Handlers/HandlerInterface.php +++ b/system/Log/Handlers/HandlerInterface.php @@ -18,16 +18,25 @@ */ interface HandlerInterface { + /** + * The reserved key under which global CI context data is stored + * in the log context array. This data comes from the Context service + * and is injected by the Logger when $logGlobalContext is enabled. + */ + public const GLOBAL_CONTEXT_KEY = '_ci_context'; + /** * Handles logging the message. * If the handler returns false, then execution of handlers * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context Full context array; may contain + * GLOBAL_CONTEXT_KEY with CI global data */ - public function handle($level, $message): bool; + public function handle($level, $message, array $context = []): bool; /** * Checks whether the Handler will handle logging items of this diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 011920e15bd1..794a145cb781 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -114,6 +114,34 @@ class Logger implements LoggerInterface */ protected $cacheLogs = false; + /** + * Whether to log the global context data. + * + * Set in app/Config/Logger.php + */ + protected bool $logGlobalContext = false; + + /** + * Whether to log per-call context data passed to log methods. + * + * Set in app/Config/Logger.php + */ + protected bool $logContext = false; + + /** + * Whether to include the stack trace when a Throwable is in the context. + * + * Set in app/Config/Logger.php + */ + protected bool $logContextTrace = false; + + /** + * Whether to keep context keys that were already used as placeholders. + * + * Set in app/Config/Logger.php + */ + protected bool $logContextUsedKeys = false; + /** * Constructor. * @@ -152,6 +180,11 @@ public function __construct($config, bool $debug = CI_DEBUG) if ($this->cacheLogs) { $this->logCache = []; } + + $this->logGlobalContext = $config->logGlobalContext ?? $this->logGlobalContext; // @phpstan-ignore nullCoalesce.property + $this->logContext = $config->logContext ?? $this->logContext; // @phpstan-ignore nullCoalesce.property + $this->logContextTrace = $config->logContextTrace ?? $this->logContextTrace; // @phpstan-ignore nullCoalesce.property + $this->logContextUsedKeys = $config->logContextUsedKeys ?? $this->logContextUsedKeys; // @phpstan-ignore nullCoalesce.property } /** @@ -248,8 +281,33 @@ public function log($level, string|Stringable $message, array $context = []): vo return; } + $interpolatedKeys = array_keys(array_filter( + $context, + static fn ($key): bool => str_contains((string) $message, '{' . $key . '}'), + ARRAY_FILTER_USE_KEY, + )); + $message = $this->interpolate($message, $context); + if ($this->logContext) { + if (! $this->logContextUsedKeys) { + foreach ($interpolatedKeys as $key) { + unset($context[$key]); + } + } + + $context = $this->normalizeContext($context); + } else { + $context = []; + } + + if ($this->logGlobalContext) { + $globalContext = service('context')->getAll(); + if ($globalContext !== []) { + $context[HandlerInterface::GLOBAL_CONTEXT_KEY] = $globalContext; + } + } + if ($this->cacheLogs) { $this->logCache[] = ['level' => $level, 'msg' => $message]; } @@ -266,12 +324,45 @@ public function log($level, string|Stringable $message, array $context = []): vo } // If the handler returns false, then we don't execute any other handlers. - if (! $handler->setDateFormat($this->dateFormat)->handle($level, $message)) { + if (! $handler->setDateFormat($this->dateFormat)->handle($level, $message, $context)) { break; } } } + /** + * Normalizes context values for structured logging. + * Per PSR-3, if an Exception is given to produce a stack trace, it MUST be + * in a key named "exception". Only that key is converted into an array + * representation. + * + * @param array $context + * + * @return array + */ + protected function normalizeContext(array $context): array + { + if (isset($context['exception']) && $context['exception'] instanceof Throwable) { + $value = $context['exception']; + + $normalized = [ + 'class' => $value::class, + 'message' => $value->getMessage(), + 'code' => $value->getCode(), + 'file' => clean_path($value->getFile()), + 'line' => $value->getLine(), + ]; + + if ($this->logContextTrace) { + $normalized['trace'] = $value->getTraceAsString(); + } + + $context['exception'] = $normalized; + } + + return $context; + } + /** * Replaces any placeholders in the message with variables * from the context, as well as a few special items like: @@ -310,7 +401,7 @@ protected function interpolate($message, array $context = []) $replace['{post_vars}'] = '$_POST: ' . print_r(service('superglobals')->getPostArray(), true); $replace['{get_vars}'] = '$_GET: ' . print_r(service('superglobals')->getGetArray(), true); - $replace['{env}'] = ENVIRONMENT; + $replace['{env}'] = service('environment')->get(); // Allow us to log the file/line that we are logging from if (str_contains($message, '{file}') || str_contains($message, '{line}')) { diff --git a/system/Model.php b/system/Model.php index 0d3c55ee0568..73a24c35cebf 100644 --- a/system/Model.php +++ b/system/Model.php @@ -16,9 +16,13 @@ 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\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Database\Query; +use CodeIgniter\Database\RawSql; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\InvalidArgumentException; @@ -26,6 +30,7 @@ use CodeIgniter\Validation\ValidationInterface; use Config\Database; use Config\Feature; +use Generator; use stdClass; /** @@ -44,13 +49,16 @@ * @method $this groupEnd() * @method $this groupStart() * @method $this having($key, $value = null, ?bool $escape = null) + * @method $this havingBetween(?string $key = null, array|null $values = null, ?bool $escape = null) * @method $this havingGroupEnd() * @method $this havingGroupStart() * @method $this havingIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + * @method $this havingNotBetween(?string $key = null, array|null $values = null, ?bool $escape = null) * @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null) * @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + * @method $this likeAny(list $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) * @method $this limit(?int $value = null, ?int $offset = 0) * @method $this notGroupStart() * @method $this notHavingGroupStart() @@ -60,17 +68,25 @@ * @method $this orderBy(string $orderBy, string $direction = '', ?bool $escape = null) * @method $this orGroupStart() * @method $this orHaving($key, $value = null, ?bool $escape = null) + * @method $this orHavingBetween(?string $key = null, array|null $values = null, ?bool $escape = null) * @method $this orHavingGroupStart() * @method $this orHavingIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + * @method $this orHavingNotBetween(?string $key = null, array|null $values = null, ?bool $escape = null) * @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + * @method $this orLikeAny(list $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) * @method $this orNotGroupStart() * @method $this orNotHavingGroupStart() * @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) * @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) * @method $this orWhere($key, $value = null, ?bool $escape = null) + * @method $this orWhereBetween(?string $key = null, array|null $values = null, ?bool $escape = null) + * @method $this orWhereColumn(string $first, string $second, ?bool $escape = null) + * @method $this orWhereExists($subquery) * @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null) + * @method $this orWhereNotBetween(?string $key = null, array|null $values = null, ?bool $escape = null) + * @method $this orWhereNotExists($subquery) * @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this select($select = '*', ?bool $escape = null) * @method $this selectAvg(string $select = '', string $alias = '') @@ -81,7 +97,12 @@ * @method $this when($condition, callable $callback, ?callable $defaultCallback = null) * @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null) * @method $this where($key, $value = null, ?bool $escape = null) + * @method $this whereBetween(?string $key = null, array|null $values = null, ?bool $escape = null) + * @method $this whereColumn(string $first, string $second, ?bool $escape = null) + * @method $this whereExists($subquery) * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null) + * @method $this whereNotBetween(?string $key = null, array|null $values = null, ?bool $escape = null) + * @method $this whereNotExists($subquery) * @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null) * * @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null) @@ -510,6 +531,52 @@ public function getIdValue($row) } public function countAllResults(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->countAllResults($reset); + } + + /** + * Explains the current Model query. + * + * @return BaseResult|false|Query|string Returns a SQL string if in test mode. + */ + public function explain(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->explain($reset); + } + + /** + * Determines whether the current Model query would return at least one row. + * + * @return bool|string Returns a SQL string if in test mode. + */ + public function exists(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->exists($reset); + } + + /** + * Determines whether the current Model query would not return any rows. + * + * @return bool|string Returns a SQL string if in test mode. + */ + public function doesntExist(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->doesntExist($reset); + } + + /** + * Applies the Model soft-delete constraint before terminal Builder operations. + */ + private function prepareSoftDeleteQuery(bool $reset): void { if ($this->tempUseSoftDeletes) { $this->builder()->where($this->table . '.' . $this->deletedField, null); @@ -521,21 +588,19 @@ public function countAllResults(bool $reset = true, bool $test = false) $this->tempUseSoftDeletes = $reset ? $this->useSoftDeletes : ($this->useSoftDeletes ? false : $this->useSoftDeletes); - - return $this->builder()->testMode($test)->countAllResults($reset); } /** - * {@inheritDoc} + * Iterates over the result set in chunks of the specified size. * - * Works with `$this->builder` to get the Compiled select to - * determine the rows to operate on. - * This method works only with dbCalls. + * @param int $size The number of records to retrieve in each chunk. + * + * @return Generator>|list> */ - public function chunk(int $size, Closure $userFunc) + private function iterateChunks(int $size): Generator { if ($size <= 0) { - throw new InvalidArgumentException('chunk() requires a positive integer for the $size argument.'); + throw new InvalidArgumentException('$size must be a positive integer.'); } $total = $this->builder()->countAllResults(false); @@ -557,6 +622,16 @@ public function chunk(int $size, Closure $userFunc) continue; } + yield $rows; + } + } + + /** + * {@inheritDoc} + */ + public function chunk(int $size, Closure $userFunc) + { + foreach ($this->iterateChunks($size) as $rows) { foreach ($rows as $row) { if ($userFunc($row) === false) { return; @@ -565,6 +640,18 @@ public function chunk(int $size, Closure $userFunc) } } + /** + * {@inheritDoc} + */ + public function chunkRows(int $size, Closure $userFunc): void + { + foreach ($this->iterateChunks($size) as $rows) { + if ($userFunc($rows) === false) { + return; + } + } + } + /** * Provides a shared instance of the Query Builder. * @@ -680,6 +767,8 @@ protected function doProtectFieldsForInsert(array $row): array throw DataException::forInvalidAllowedFields(static::class); } + $this->ensureNoDisallowedFields($row, $this->useAutoIncrement === false ? [$this->primaryKey] : []); + foreach (array_keys($row) as $key) { // Do not remove the non-auto-incrementing primary key data. if ($this->useAutoIncrement === false && $key === $this->primaryKey) { @@ -694,6 +783,61 @@ protected function doProtectFieldsForInsert(array $row): array return $row; } + protected function doProtectFieldsForUpdate(array $row): array + { + $this->ensureNoDisallowedFields($row, [$this->primaryKey]); + + return $this->doProtectFields($row); + } + + /** + * Finds the first row matching attributes or inserts a new row. + * + * Note: without a DB unique constraint, this is not race-safe. + * + * @param array|object $attributes + * @param array|object $values + * + * @return array|false|object + */ + public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object + { + if (is_object($attributes)) { + $attributes = $this->transformDataToArray($attributes, 'insert'); + } + + if ($attributes === []) { + throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.'); + } + + $row = $this->where($attributes)->first(); + if ($row !== null) { + return $row; + } + + if (is_object($values)) { + $values = $this->transformDataToArray($values, 'insert'); + } + + $data = array_merge($attributes, $values); + + try { + $id = $this->insert($data); + } catch (UniqueConstraintViolationException) { + return $this->where($attributes)->first() ?? false; + } + + if ($id === false) { + if ($this->db->getLastException() instanceof UniqueConstraintViolationException) { + return $this->where($attributes)->first() ?? false; + } + + return false; + } + + return $this->where($this->primaryKey, $id)->first() ?? false; + } + public function update($id = null, $row = null): bool { if (isset($this->tempData['data'])) { diff --git a/system/Pager/Pager.php b/system/Pager/Pager.php index a4883f3809ae..942288c6a857 100644 --- a/system/Pager/Pager.php +++ b/system/Pager/Pager.php @@ -425,7 +425,7 @@ protected function calculateCurrentPage(string $group) if (array_key_exists($group, $this->segment)) { try { $this->groups[$group]['currentPage'] = (int) $this->groups[$group]['currentUri'] - ->setSilent(false)->getSegment($this->segment[$group]); + ->getSegment($this->segment[$group]); } catch (HTTPException) { $this->groups[$group]['currentPage'] = 1; } diff --git a/system/Router/Attributes/Restrict.php b/system/Router/Attributes/Restrict.php index 79344a7c6982..d349d1ded0ee 100644 --- a/system/Router/Attributes/Restrict.php +++ b/system/Router/Attributes/Restrict.php @@ -69,7 +69,7 @@ protected function checkEnvironment(): void return; } - $currentEnv = ENVIRONMENT; + $currentEnv = service('environment')->get(); $allowed = []; $denied = []; diff --git a/system/Router/AutoRouter.php b/system/Router/AutoRouter.php index 3a81f3a7bc6b..04c630ab62f2 100644 --- a/system/Router/AutoRouter.php +++ b/system/Router/AutoRouter.php @@ -166,7 +166,7 @@ public function getRoute(string $uri, string $httpVerb): array * Tells the system whether we should translate URI dashes or not * in the URI from a dash to an underscore. * - * @deprecated This method should be removed. + * @deprecated 4.2.0 This method should be removed. */ public function setTranslateURIDashes(bool $val = false): self { @@ -238,7 +238,7 @@ private function isValidSegment(string $segment): bool * * @param bool $validate if true, checks to make sure $dir consists of only PSR4 compliant segments * - * @deprecated This method should be removed. + * @deprecated 4.2.0 This method should be removed. * * @return void */ @@ -271,7 +271,7 @@ public function setDirectory(?string $dir = null, bool $append = false, bool $va * Returns the name of the sub-directory the controller is in, * if any. Relative to APPPATH.'Controllers'. * - * @deprecated This method should be removed. + * @deprecated 4.2.0 This method should be removed. */ public function directory(): string { diff --git a/system/Router/AutoRouterImproved.php b/system/Router/AutoRouterImproved.php index a00c04d265d7..8ff775a5653e 100644 --- a/system/Router/AutoRouterImproved.php +++ b/system/Router/AutoRouterImproved.php @@ -442,7 +442,24 @@ private function checkParameters(): void throw new MethodNotFoundException(); } - if (count($refParams) < count($this->params)) { + // Count only parameters that consume one URI segment each. A variadic + // parameter absorbs any number of trailing segments, so there is no + // upper bound to enforce. + $scalarCount = 0; + + foreach ($refParams as $param) { + [$kind] = CallableParamClassifier::classify($param); + + if ($kind === ParamKind::Variadic) { + return; + } + + if ($kind === ParamKind::Scalar) { + $scalarCount++; + } + } + + if ($scalarCount < count($this->params)) { throw new PageNotFoundException( 'The param count in the URI are greater than the controller method params.' . ' Handler:' . $this->controller . '::' . $this->method diff --git a/system/Router/CallableParamClassifier.php b/system/Router/CallableParamClassifier.php new file mode 100644 index 000000000000..3094041585e7 --- /dev/null +++ b/system/Router/CallableParamClassifier.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router; + +use CodeIgniter\HTTP\FormRequest; +use ReflectionParameter; + +/** + * Single source of truth for how the auto-router and the dispatcher + * interpret a reflected callable parameter with respect to URI segment + * consumption. Keeps AutoRouterImproved::checkParameters() and + * CodeIgniter::resolveCallableParams() aligned on "FormRequest", + * "variadic", and "scalar URI consumer". + */ +final class CallableParamClassifier +{ + /** + * Returns the param kind and, when the kind is FormRequest, the resolved + * FormRequest class name so the caller does not need to re-inspect the + * parameter's type to inject it. + * + * @return array{ParamKind, class-string|null} + */ + public static function classify(ReflectionParameter $param): array + { + $formRequestClass = FormRequest::getFormRequestClass($param); + + if ($formRequestClass !== null) { + return [ParamKind::FormRequest, $formRequestClass]; + } + + if ($param->isVariadic()) { + return [ParamKind::Variadic, null]; + } + + return [ParamKind::Scalar, null]; + } +} diff --git a/system/Router/ParamKind.php b/system/Router/ParamKind.php new file mode 100644 index 000000000000..00f811a3e2d4 --- /dev/null +++ b/system/Router/ParamKind.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router; + +/** + * Classifies how a reflected callable parameter consumes URI segments + * during auto-routing and dispatch. + */ +enum ParamKind +{ + case FormRequest; + case Variadic; + case Scalar; +} diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 3c08958604d2..0710c6902836 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -667,7 +667,7 @@ public function map(array $routes = [], ?array $options = null): RouteCollection * Example: * $routes->add('news', 'Posts::index'); * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function add(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1013,7 +1013,7 @@ public function presenter(string $name, ?array $options = null): RouteCollection * Example: * $route->match( ['GET', 'POST'], 'users/(:num)', 'users/$1); * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface { @@ -1045,7 +1045,7 @@ public function match(array $verbs = [], string $from = '', $to = '', ?array $op /** * Specifies a route that is only available to GET requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function get(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1057,7 +1057,7 @@ public function get(string $from, $to, ?array $options = null): RouteCollectionI /** * Specifies a route that is only available to POST requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function post(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1069,7 +1069,7 @@ public function post(string $from, $to, ?array $options = null): RouteCollection /** * Specifies a route that is only available to PUT requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function put(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1081,7 +1081,7 @@ public function put(string $from, $to, ?array $options = null): RouteCollectionI /** * Specifies a route that is only available to DELETE requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1093,7 +1093,7 @@ public function delete(string $from, $to, ?array $options = null): RouteCollecti /** * Specifies a route that is only available to HEAD requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function head(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1105,7 +1105,7 @@ public function head(string $from, $to, ?array $options = null): RouteCollection /** * Specifies a route that is only available to PATCH requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1117,7 +1117,7 @@ public function patch(string $from, $to, ?array $options = null): RouteCollectio /** * Specifies a route that is only available to OPTIONS requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function options(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1129,7 +1129,7 @@ public function options(string $from, $to, ?array $options = null): RouteCollect /** * Specifies a route that is only available to command-line requests. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to */ public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1163,7 +1163,7 @@ public function view(string $from, string $view, ?array $options = null): RouteC */ public function environment(string $env, Closure $callback): RouteCollectionInterface { - if ($env === ENVIRONMENT) { + if (service('environment')->is($env)) { $callback($this); } @@ -1255,7 +1255,7 @@ public function reverseRoute(string $search, ...$params) /** * Replaces the {locale} tag with the current application locale * - * @deprecated Unused. + * @deprecated 4.3.0 Unused. */ protected function localizeRoute(string $route): string { @@ -1309,7 +1309,7 @@ public function getFiltersForRoute(string $search, ?string $verb = null): array * * @throws RouterException * - * @deprecated Unused. Now uses buildReverseRoute(). + * @deprecated 4.3.0 Unused. Now uses buildReverseRoute(). */ protected function fillRouteParams(string $from, ?array $params = null): string { @@ -1435,7 +1435,7 @@ private function replaceLocale(string $route, ?string $locale = null): string * the request method(s) that this route will work for. They can be separated * by a pipe character "|" if there is more than one. * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|Closure|string $to * * @return void */ @@ -1772,7 +1772,7 @@ public function getRegisteredControllers(?string $verb = '*'): array } /** - * @param (Closure(mixed...): (ResponseInterface|string|void))|string $handler Handler + * @param Closure|string $handler Handler * * @return string|null Controller classname */ diff --git a/system/Router/RouteCollectionInterface.php b/system/Router/RouteCollectionInterface.php index 10587d81ee4c..af89b5f63017 100644 --- a/system/Router/RouteCollectionInterface.php +++ b/system/Router/RouteCollectionInterface.php @@ -28,9 +28,12 @@ interface RouteCollectionInterface /** * Adds a single route to the collection. * - * @param string $from The route path (with placeholders or regex) - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to The route handler - * @param array|null $options The route options + * Route handler closure parameters are resolved dynamically via reflection, + * so their callable signatures cannot be expressed precisely in PHPDoc. + * + * @param string $from The route path (with placeholders or regex) + * @param array|Closure|string $to The route handler + * @param array|null $options The route options * * @return RouteCollectionInterface */ diff --git a/system/Router/Router.php b/system/Router/Router.php index 063bb5074cbb..924a1ae7ea5f 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -71,7 +71,7 @@ class Router implements RouterInterface /** * The name of the controller class. * - * @var (Closure(mixed...): (ResponseInterface|string|void))|string + * @var Closure|string */ protected $controller; @@ -198,7 +198,7 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request * * @param string|null $uri URI path relative to baseURL * - * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure + * @return Closure|string Controller classname or Closure * * @throws BadRequestException * @throws PageNotFoundException @@ -271,7 +271,7 @@ public function getFilters(): array /** * Returns the name of the matched controller or closure. * - * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure + * @return Closure|string Controller classname or Closure */ public function controllerName() { @@ -382,7 +382,7 @@ public function setIndexPage($page): self * Tells the system whether we should translate URI dashes or not * in the URI from a dash to an underscore. * - * @deprecated This method should be removed. + * @deprecated 4.2.0 This method should be removed. */ public function setTranslateURIDashes(bool $val = false): self { @@ -599,7 +599,7 @@ public function autoRoute(string $uri) * * @return array returns an array of remaining uri segments that don't map onto a directory * - * @deprecated this function name does not properly describe its behavior so it has been deprecated + * @deprecated 4.1.2 this function name does not properly describe its behavior so it has been deprecated * * @codeCoverageIgnore */ @@ -615,7 +615,7 @@ protected function validateRequest(array $segments): array * * @return array returns an array of remaining uri segments that don't map onto a directory * - * @deprecated Not used. Moved to AutoRouter class. + * @deprecated 4.2.0 Not used. Moved to AutoRouter class. */ protected function scanControllers(array $segments): array { @@ -663,7 +663,7 @@ protected function scanControllers(array $segments): array * * @return void * - * @deprecated This method should be removed. + * @deprecated 4.2.0 This method should be removed. */ public function setDirectory(?string $dir = null, bool $append = false, bool $validate = true) { @@ -681,7 +681,7 @@ public function setDirectory(?string $dir = null, bool $append = false, bool $va * * regex comes from https://www.php.net/manual/en/language.variables.basics.php * - * @deprecated Moved to AutoRouter class. + * @deprecated 4.2.0 Moved to AutoRouter class. */ private function isValidSegment(string $segment): bool { @@ -723,7 +723,7 @@ protected function setRequest(array $segments = []) /** * Sets the default controller based on the info set in the RouteCollection. * - * @deprecated This was an unnecessary method, so it is no longer used. + * @deprecated 4.2.0 This was an unnecessary method, so it is no longer used. * * @return void */ diff --git a/system/Router/RouterInterface.php b/system/Router/RouterInterface.php index c50de86d43df..ef84eeade8e4 100644 --- a/system/Router/RouterInterface.php +++ b/system/Router/RouterInterface.php @@ -15,7 +15,6 @@ use Closure; use CodeIgniter\HTTP\Request; -use CodeIgniter\HTTP\ResponseInterface; /** * Expected behavior of a Router. @@ -32,14 +31,14 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request * * @param string|null $uri URI path relative to baseURL * - * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure + * @return Closure|string Controller classname or Closure */ public function handle(?string $uri = null); /** * Returns the name of the matched controller. * - * @return (Closure(mixed...): (ResponseInterface|string|void))|string Controller classname or Closure + * @return Closure|string Controller classname or Closure */ public function controllerName(); diff --git a/system/Security/Exceptions/SecurityException.php b/system/Security/Exceptions/SecurityException.php index 09ecc26e6bc8..14b8a5464aa5 100644 --- a/system/Security/Exceptions/SecurityException.php +++ b/system/Security/Exceptions/SecurityException.php @@ -69,16 +69,4 @@ public static function forInvalidControlChars(string $source, string $string) 400, ); } - - /** - * @deprecated Use `CookieException::forInvalidSameSite()` instead. - * - * @codeCoverageIgnore - * - * @return static - */ - public static function forInvalidSameSite(string $samesite) - { - return new static(lang('Security.invalidSameSite', [$samesite])); - } } diff --git a/system/Security/Security.php b/system/Security/Security.php index 4ac0de3f8ff8..3e8d919a6cbf 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -21,16 +21,13 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\I18n\Time; use CodeIgniter\Security\Exceptions\SecurityException; -use CodeIgniter\Session\Session; +use CodeIgniter\Session\SessionInterface; use Config\Cookie as CookieConfig; use Config\Security as SecurityConfig; -use ErrorException; use JsonException; use SensitiveParameter; /** - * Class Security - * * Provides methods that help protect your site against * Cross-Site Request Forgery attacks. * @@ -38,29 +35,21 @@ */ class Security implements SecurityInterface { - public const CSRF_PROTECTION_COOKIE = 'cookie'; - public const CSRF_PROTECTION_SESSION = 'session'; - protected const CSRF_HASH_BYTES = 16; + public const CSRF_PROTECTION_COOKIE = 'cookie'; + public const CSRF_PROTECTION_SESSION = 'session'; + private const FETCH_METADATA_ALLOW = 'allow'; + private const FETCH_METADATA_FALLBACK = 'fallback'; + private const FETCH_METADATA_REJECT = 'reject'; /** - * CSRF Protection Method - * - * Protection Method for Cross Site Request Forgery protection. - * - * @var string 'cookie' or 'session' - * - * @deprecated 4.4.0 Use $this->config->csrfProtection. + * CSRF hash length in bytes. */ - protected $csrfProtection = self::CSRF_PROTECTION_COOKIE; + protected const CSRF_HASH_BYTES = 16; /** - * CSRF Token Randomization - * - * @var bool - * - * @deprecated 4.4.0 Use $this->config->tokenRandomize. + * CSRF hash length in hexadecimal characters. */ - protected $tokenRandomize = false; + protected const CSRF_HASH_HEX = self::CSRF_HASH_BYTES * 2; /** * CSRF Hash (without randomization) @@ -72,31 +61,9 @@ class Security implements SecurityInterface protected $hash; /** - * CSRF Token Name - * - * Token name for Cross Site Request Forgery protection. - * - * @var string - * - * @deprecated 4.4.0 Use $this->config->tokenName. - */ - protected $tokenName = 'csrf_token_name'; - - /** - * CSRF Header Name - * - * Header name for Cross Site Request Forgery protection. - * - * @var string - * - * @deprecated 4.4.0 Use $this->config->headerName. - */ - protected $headerName = 'X-CSRF-TOKEN'; - - /** - * The CSRF Cookie instance. - * * @var Cookie + * + * @deprecated v4.8.0 Use service('response')->getCookie() instead. */ protected $cookie; @@ -109,69 +76,12 @@ class Security implements SecurityInterface */ protected $cookieName = 'csrf_cookie_name'; - /** - * CSRF Expires - * - * Expiration time for Cross Site Request Forgery protection cookie. - * - * Defaults to two hours (in seconds). - * - * @var int - * - * @deprecated 4.4.0 Use $this->config->expires. - */ - protected $expires = 7200; - - /** - * CSRF Regenerate - * - * Regenerate CSRF Token on every request. - * - * @var bool - * - * @deprecated 4.4.0 Use $this->config->regenerate. - */ - protected $regenerate = true; - - /** - * CSRF Redirect - * - * Redirect to previous page with error on failure. - * - * @var bool - * - * @deprecated 4.4.0 Use $this->config->redirect. - */ - protected $redirect = false; - - /** - * CSRF SameSite - * - * Setting for CSRF SameSite cookie token. - * - * Allowed values are: None - Lax - Strict - ''. - * - * Defaults to `Lax` as recommended in this link: - * - * @see https://portswigger.net/web-security/csrf/samesite-cookies - * - * @var string - * - * @deprecated `Config\Cookie` $samesite property is used. - */ - protected $samesite = Cookie::SAMESITE_LAX; - - private readonly IncomingRequest $request; - /** * CSRF Cookie Name without Prefix */ private ?string $rawCookieName = null; - /** - * Session instance. - */ - private ?Session $session = null; + private ?SessionInterface $session = null; /** * CSRF Hash in Request Cookie @@ -181,59 +91,26 @@ class Security implements SecurityInterface */ private ?string $hashInCookie = null; - /** - * Security Config - */ - protected SecurityConfig $config; - - /** - * Constructor. - * - * Stores our configuration and fires off the init() method to setup - * initial state. - */ - public function __construct(SecurityConfig $config) + public function __construct(protected SecurityConfig $config) { - $this->config = $config; - $this->rawCookieName = $config->cookieName; - if ($this->isCSRFCookie()) { - $cookie = config(CookieConfig::class); - - $this->configureCookie($cookie); + if ($this->isCsrfCookie()) { + $this->configureCookie(config(CookieConfig::class)); } else { - // Session based CSRF protection $this->configureSession(); } - $this->request = service('request'); - $this->hashInCookie = $this->request->getCookie($this->cookieName); + $this->hashInCookie = service('request')->getCookie($this->cookieName); $this->restoreHash(); + if ($this->hash === null) { $this->generateHash(); } } - private function isCSRFCookie(): bool - { - return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE; - } - - private function configureSession(): void - { - $this->session = service('session'); - } - - private function configureCookie(CookieConfig $cookie): void - { - $cookiePrefix = $cookie->prefix; - $this->cookieName = $cookiePrefix . $this->rawCookieName; - Cookie::setDefaults($cookie); - } - - public function verify(RequestInterface $request) + public function verify(RequestInterface $request): static { $method = $request->getMethod(); @@ -244,6 +121,27 @@ public function verify(RequestInterface $request) assert($request instanceof IncomingRequest); + $decision = $this->fetchMetadataDecision($request); + + if ($decision === self::FETCH_METADATA_ALLOW) { + $this->removeTokenInRequest($request); + + log_message('info', 'CSRF Fetch Metadata verified.'); + + return $this; + } + + if ($decision === self::FETCH_METADATA_REJECT) { + throw SecurityException::forDisallowedAction(); + } + + $this->verifyToken($request); + + return $this; + } + + private function verifyToken(IncomingRequest $request): void + { $postedToken = $this->getPostedToken($request); try { @@ -265,12 +163,144 @@ public function verify(RequestInterface $request) } log_message('info', 'CSRF token verified.'); + } - return $this; + public function getHash(): ?string + { + return $this->config->tokenRandomize && isset($this->hash) + ? $this->randomize($this->hash) + : $this->hash; + } + + public function getTokenName(): string + { + return $this->config->tokenName; + } + + public function getHeaderName(): string + { + return $this->config->headerName; + } + + public function getCookieName(): string + { + return $this->config->cookieName; + } + + public function shouldRedirect(): bool + { + return $this->config->redirect; + } + + public function shouldUseFetchMetadata(): bool + { + return $this->config->csrfFetchMetadata ?? false; // @phpstan-ignore nullCoalesce.initializedProperty + } + + /** + * @phpstan-assert string $this->hash + */ + public function generateHash(): string + { + $this->hash = bin2hex(random_bytes(static::CSRF_HASH_BYTES)); + + if ($this->isCsrfCookie()) { + $this->saveHashInCookie(); + } else { + $this->saveHashInSession(); + } + + return $this->hash; + } + + /** + * Randomize hash to avoid BREACH attacks. + */ + protected function randomize(string $hash): string + { + $keyBinary = random_bytes(static::CSRF_HASH_BYTES); + $hashBinary = hex2bin($hash); + + if ($hashBinary === false) { + throw new LogicException('$hash is invalid: ' . $hash); + } + + return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary); + } + + /** + * Derandomize the token. + * + * @throws InvalidArgumentException + */ + protected function derandomize(#[SensitiveParameter] string $token): string + { + // The token should be in the format of `randomizedHash` + `key`, + // where both `randomizedHash` and `key` are hex strings of length CSRF_HASH_HEX. + if (strlen($token) !== self::CSRF_HASH_HEX * 2) { + throw new InvalidArgumentException('Invalid CSRF token.'); + } + + $keyBinary = hex2bin(substr($token, -self::CSRF_HASH_HEX)); + $hashBinary = hex2bin(substr($token, 0, self::CSRF_HASH_HEX)); + + if ($hashBinary === false || $keyBinary === false) { + throw new InvalidArgumentException('Invalid CSRF token.'); + } + + return bin2hex($hashBinary ^ $keyBinary); + } + + private function isCsrfCookie(): bool + { + return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE; + } + + /** + * @return self::FETCH_METADATA_* + */ + private function fetchMetadataDecision(IncomingRequest $request): string + { + if (! $this->shouldUseFetchMetadata()) { + return self::FETCH_METADATA_FALLBACK; + } + + $fetchSite = strtolower($request->getHeaderLine('Sec-Fetch-Site')); + + if ($fetchSite === 'same-origin') { + return self::FETCH_METADATA_ALLOW; + } + + if ($fetchSite === 'cross-site') { + return self::FETCH_METADATA_REJECT; + } + + if ($fetchSite === 'same-site') { + return ($this->config->csrfFetchMetadataRejectSameSite ?? false) // @phpstan-ignore nullCoalesce.initializedProperty + ? self::FETCH_METADATA_REJECT + : self::FETCH_METADATA_FALLBACK; + } + + return self::FETCH_METADATA_FALLBACK; + } + + /** + * @phpstan-assert SessionInterface $this->session + */ + private function configureSession(): void + { + $this->session = service('session'); + } + + private function configureCookie(CookieConfig $cookie): void + { + $this->cookieName = $cookie->prefix . $this->rawCookieName; + + Cookie::setDefaults($cookie); } /** - * Remove token in POST or JSON request data + * Remove token in POST, JSON, or form-encoded data to prevent it from being accidentally leaked. */ private function removeTokenInRequest(IncomingRequest $request): void { @@ -310,6 +340,10 @@ private function removeTokenInRequest(IncomingRequest $request): void // If the token is found in form-encoded data, we can safely remove it. parse_str($body, $result); + if (! array_key_exists($tokenName, $result)) { + return; + } + unset($result[$tokenName]); $request->setBody(http_build_query($result)); } @@ -376,140 +410,22 @@ private function isNonEmptyTokenString(mixed $token): bool return is_string($token) && $token !== ''; } - /** - * Returns the CSRF Token. - */ - public function getHash(): ?string - { - return $this->config->tokenRandomize ? $this->randomize($this->hash) : $this->hash; - } - - /** - * Randomize hash to avoid BREACH attacks. - * - * @params string $hash CSRF hash - * - * @return string CSRF token - */ - protected function randomize(string $hash): string - { - $keyBinary = random_bytes(static::CSRF_HASH_BYTES); - $hashBinary = hex2bin($hash); - - if ($hashBinary === false) { - throw new LogicException('$hash is invalid: ' . $hash); - } - - return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary); - } - - /** - * Derandomize the token. - * - * @params string $token CSRF token - * - * @return string CSRF hash - * - * @throws InvalidArgumentException "hex2bin(): Hexadecimal input string must have an even length" - */ - 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((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); - } - } - - /** - * Returns the CSRF Token Name. - */ - public function getTokenName(): string - { - return $this->config->tokenName; - } - - /** - * Returns the CSRF Header Name. - */ - public function getHeaderName(): string - { - return $this->config->headerName; - } - - /** - * Returns the CSRF Cookie Name. - */ - public function getCookieName(): string - { - return $this->config->cookieName; - } - - /** - * Check if request should be redirect on failure. - */ - public function shouldRedirect(): bool - { - return $this->config->redirect; - } - - /** - * Sanitize Filename - * - * Tries to sanitize filenames in order to prevent directory traversal attempts - * and other security threats, which is particularly useful for files that - * were supplied via user input. - * - * If it is acceptable for the user input to include relative paths, - * e.g. file/in/some/approved/folder.txt, you can set the second optional - * parameter, $relativePath to TRUE. - * - * @deprecated 4.6.2 Use `sanitize_filename()` instead - * - * @param string $str Input file name - * @param bool $relativePath Whether to preserve paths - */ - public function sanitizeFilename(string $str, bool $relativePath = false): string - { - helper('security'); - - return sanitize_filename($str, $relativePath); - } - /** * Restore hash from Session or Cookie */ private function restoreHash(): void { - if ($this->isCSRFCookie()) { - if ($this->isHashInCookie()) { - $this->hash = $this->hashInCookie; - } - } elseif ($this->session->has($this->config->tokenName)) { - // Session based CSRF protection - $this->hash = $this->session->get($this->config->tokenName); + if ($this->isCsrfCookie()) { + $this->hash = $this->isHashInCookie() ? $this->hashInCookie : null; + + return; } - } - /** - * Generates (Regenerates) the CSRF Hash. - */ - public function generateHash(): string - { - $this->hash = bin2hex(random_bytes(static::CSRF_HASH_BYTES)); + $tokenName = $this->config->tokenName; - if ($this->isCSRFCookie()) { - $this->saveHashInCookie(); - } else { - // Session based CSRF protection - $this->saveHashInSession(); + if ($this->session instanceof SessionInterface && $this->session->has($tokenName)) { + $this->hash = $this->session->get($tokenName); } - - return $this->hash; } private function isHashInCookie(): bool @@ -518,28 +434,33 @@ private function isHashInCookie(): bool return false; } - $length = static::CSRF_HASH_BYTES * 2; - $pattern = '#^[0-9a-f]{' . $length . '}$#iS'; + if (strlen($this->hashInCookie) !== self::CSRF_HASH_HEX) { + return false; + } - return preg_match($pattern, $this->hashInCookie) === 1; + return ctype_xdigit($this->hashInCookie); } private function saveHashInCookie(): void { - $this->cookie = new Cookie( + $expires = $this->config->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expires; + + $cookie = new Cookie( $this->rawCookieName, $this->hash, - [ - 'expires' => $this->config->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expires, - ], + compact('expires'), ); - $response = service('response'); - $response->setCookie($this->cookie); + service('response')->setCookie($cookie); + + // For backward compatibility, we also set the cookie value to $this->cookie property. + // @todo v4.8.0 Remove $this->cookie property and its usages. + $this->cookie = $cookie; } private function saveHashInSession(): void { + assert($this->session instanceof SessionInterface); $this->session->set($this->config->tokenName, $this->hash); } } diff --git a/system/Security/SecurityInterface.php b/system/Security/SecurityInterface.php index ebd8919cd109..dbbc67bf5252 100644 --- a/system/Security/SecurityInterface.php +++ b/system/Security/SecurityInterface.php @@ -17,18 +17,17 @@ use CodeIgniter\Security\Exceptions\SecurityException; /** - * Expected behavior of a Security. + * Expected behavior of a Security object providing + * protection against CSRF attacks. */ interface SecurityInterface { /** - * CSRF Verify - * - * @return $this|false + * Verify CSRF token sent with the request. * * @throws SecurityException */ - public function verify(RequestInterface $request); + public function verify(RequestInterface $request): static; /** * Returns the CSRF Hash. @@ -54,22 +53,4 @@ public function getCookieName(): string; * Check if request should be redirect on failure. */ public function shouldRedirect(): bool; - - /** - * Sanitize Filename - * - * Tries to sanitize filenames in order to prevent directory traversal attempts - * and other security threats, which is particularly useful for files that - * were supplied via user input. - * - * If it is acceptable for the user input to include relative paths, - * e.g. file/in/some/approved/folder.txt, you can set the second optional - * parameter, $relativePath to TRUE. - * - * @deprecated 4.6.2 Use `sanitize_filename()` instead - * - * @param string $str Input file name - * @param bool $relativePath Whether to preserve paths - */ - public function sanitizeFilename(string $str, bool $relativePath = false): string; } diff --git a/system/Session/Exceptions/SessionException.php b/system/Session/Exceptions/SessionException.php index 2548d5f5dc18..29c9a74ab3c1 100644 --- a/system/Session/Exceptions/SessionException.php +++ b/system/Session/Exceptions/SessionException.php @@ -56,16 +56,4 @@ public static function forInvalidSavePathFormat(string $path) { return new static(lang('Session.invalidSavePathFormat', [$path])); } - - /** - * @deprecated - * - * @return static - * - * @codeCoverageIgnore - */ - public static function forInvalidSameSiteSetting(string $samesite) - { - return new static(lang('Session.invalidSameSiteSetting', [$samesite])); - } } diff --git a/system/Session/Session.php b/system/Session/Session.php index 1998a0ea8596..bbf93a16ec96 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -86,7 +86,7 @@ public function __construct(SessionHandlerInterface $driver, SessionConfig $conf */ public function start() { - if (is_cli() && ENVIRONMENT !== 'testing') { + if (is_cli() && ! service('environment')->isTesting()) { // @codeCoverageIgnoreStart $this->logger->debug('Session: Initialization under CLI aborted.'); @@ -272,7 +272,7 @@ private function removeOldSessionCookie(): void public function destroy() { - if (ENVIRONMENT === 'testing') { + if (service('environment')->isTesting()) { return; } @@ -286,7 +286,7 @@ public function destroy() */ public function close() { - if (ENVIRONMENT === 'testing') { + if (service('environment')->isTesting()) { return; } @@ -615,7 +615,7 @@ protected function setSaveHandler() */ protected function startSession() { - if (ENVIRONMENT === 'testing') { + if (service('environment')->isTesting()) { $_SESSION = []; return; diff --git a/system/Test/CIUnitTestCase.php b/system/Test/CIUnitTestCase.php index edfa98df5c06..468f216079d4 100644 --- a/system/Test/CIUnitTestCase.php +++ b/system/Test/CIUnitTestCase.php @@ -501,6 +501,14 @@ public function assertCloseEnoughString($expected, $actual, string $message = '' return null; } + /** + * Asserts that two SQL strings are the same, ignoring newlines in the actual SQL. + */ + public function assertSameSql(string $expected, string $actual, string $message = ''): void + { + $this->assertSame($expected, str_replace(["\r\n", "\r", "\n"], ' ', $actual), $message); + } + // -------------------------------------------------------------------- // Utility // -------------------------------------------------------------------- diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index 087fc0a59d90..c45ed882699f 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -69,16 +69,7 @@ protected function withRoutes(?array $routes = null) $collection->resetRoutes(); foreach ($routes as $route) { - if ($route[0] === strtolower($route[0])) { - @trigger_error( - 'Passing lowercase HTTP method "' . $route[0] . '" is deprecated.' - . ' Use uppercase HTTP method like "' . strtoupper($route[0]) . '".', - E_USER_DEPRECATED, - ); - } - - // @todo v4.7.1 Remove the strtoupper() and use 'add' in v4.8.0 - if (! in_array(strtoupper($route[0]), ['ADD', 'CLI', ...Method::all()], true)) { + if (! in_array($route[0], ['add', 'CLI', ...Method::all()], true)) { throw new RuntimeException(sprintf( 'Invalid HTTP method "%s" provided for route "%s".', $route[0], @@ -179,26 +170,12 @@ public function skipEvents() * Calls a single URI, executes it, and returns a TestResponse * instance that can be used to run many assertions against. * - * @param string $method HTTP verb + * @param uppercase-string $method HTTP verb * * @return TestResponse */ public function call(string $method, string $path, ?array $params = null) { - if ($method === strtolower($method)) { - @trigger_error( - 'Passing lowercase HTTP method "' . $method . '" is deprecated.' - . ' Use uppercase HTTP method like "' . strtoupper($method) . '".', - E_USER_DEPRECATED, - ); - } - - /** - * @deprecated 4.5.0 - * @TODO remove this in the future. - */ - $method = strtoupper($method); - // Simulate having a blank session $_SESSION = []; service('superglobals')->setServer('REQUEST_METHOD', $method); diff --git a/system/Test/Mock/MockCURLRequest.php b/system/Test/Mock/MockCURLRequest.php index 059b83114927..c525db589649 100644 --- a/system/Test/Mock/MockCURLRequest.php +++ b/system/Test/Mock/MockCURLRequest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Test\Mock; use CodeIgniter\HTTP\CURLRequest; +use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\URI; /** @@ -33,6 +34,21 @@ class MockCURLRequest extends CURLRequest */ protected $output = ''; + /** + * @var list + */ + protected array $outputs = []; + + /** + * @var list + */ + protected array $curlErrors = []; + + /** + * @var list + */ + protected array $sleeps = []; + /** * @param string $output * @@ -45,6 +61,30 @@ public function setOutput($output) return $this; } + /** + * @param list $outputs + * + * @return $this + */ + public function setOutputs(array $outputs) + { + $this->outputs = $outputs; + + return $this; + } + + /** + * @param list $curlErrors + * + * @return $this + */ + public function setCurlErrors(array $curlErrors) + { + $this->curlErrors = $curlErrors; + + return $this; + } + /** * @param array $curlOptions */ @@ -54,7 +94,28 @@ protected function sendRequest(array $curlOptions = []): string $this->curl_options = $curlOptions; - return $this->output; + if ($this->curlErrors !== []) { + [$this->lastCurlError, $message] = array_shift($this->curlErrors); + + throw HTTPException::forCurlError((string) $this->lastCurlError, $message); + } + + return $this->outputs !== [] ? array_shift($this->outputs) : $this->output; + } + + protected function sleep(float $seconds): void + { + $this->sleeps[] = $seconds; + } + + /** + * for testing purposes only + * + * @return list + */ + public function getSleeps(): array + { + return $this->sleeps; } /** diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 27af8a1ad213..4a672dd59fca 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -16,10 +16,12 @@ use Closure; use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\Handlers\BaseHandler; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\I18n\Time; use PHPUnit\Framework\Assert; -class MockCache extends BaseHandler implements CacheInterface +class MockCache extends BaseHandler implements CacheInterface, LockStoreProviderInterface { /** * Mock cache storage. @@ -42,6 +44,8 @@ class MockCache extends BaseHandler implements CacheInterface */ protected $bypass = false; + private ?MockLockStore $lockStore = null; + /** * Takes care of any handler-specific setup that must be done. */ @@ -68,7 +72,7 @@ public function get(string $key): mixed * * @return bool|null */ - public function remember(string $key, int $ttl, Closure $callback): mixed + public function remember(string $key, callable|int $ttl, Closure $callback): mixed { $value = $this->get($key); @@ -76,7 +80,13 @@ public function remember(string $key, int $ttl, Closure $callback): mixed return $value; } - $this->save($key, $value = $callback(), $ttl); + $value = $callback(); + + if (is_callable($ttl)) { + $ttl = $ttl($value); + } + + $this->save($key, $value, $ttl); return $value; } @@ -180,6 +190,7 @@ public function clean(): true { $this->cache = []; $this->expirations = []; + $this->lockStore?->clean(); return true; } @@ -227,6 +238,11 @@ public function isSupported(): bool return true; } + public function lockStore(): LockStoreInterface + { + return $this->lockStore ??= new MockLockStore(); + } + // -------------------------------------------------------------------- // Test Helpers // -------------------------------------------------------------------- diff --git a/system/Test/Mock/MockCodeIgniter.php b/system/Test/Mock/MockCodeIgniter.php index 75de5d5722a6..2cfaf0d86afe 100644 --- a/system/Test/Mock/MockCodeIgniter.php +++ b/system/Test/Mock/MockCodeIgniter.php @@ -18,14 +18,4 @@ class MockCodeIgniter extends CodeIgniter { protected ?string $context = 'web'; - - /** - * @param int $code - * - * @deprecated 4.4.0 No longer Used. Moved to index.php. - */ - protected function callExit($code) - { - // Do not call exit() in testing. - } } diff --git a/system/Test/Mock/MockLockStore.php b/system/Test/Mock/MockLockStore.php new file mode 100644 index 000000000000..a59042db806c --- /dev/null +++ b/system/Test/Mock/MockLockStore.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Mock; + +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\I18n\Time; + +class MockLockStore implements LockStoreInterface +{ + /** + * @var array + */ + private array $locks = []; + + public function acquireLock(string $key, string $owner, int $ttl): bool + { + if ($this->getLockOwner($key) !== null) { + return false; + } + + $this->locks[$key] = [ + 'owner' => $owner, + 'expires' => Time::now()->getTimestamp() + $ttl, + ]; + + return true; + } + + public function releaseLock(string $key, string $owner): bool + { + if ($this->getLockOwner($key) !== $owner) { + return false; + } + + unset($this->locks[$key]); + + return true; + } + + public function forceReleaseLock(string $key): bool + { + unset($this->locks[$key]); + + return true; + } + + public function refreshLock(string $key, string $owner, int $ttl): bool + { + if ($this->getLockOwner($key) !== $owner) { + return false; + } + + $this->locks[$key]['expires'] = Time::now()->getTimestamp() + $ttl; + + return true; + } + + public function getLockOwner(string $key): ?string + { + if (! isset($this->locks[$key])) { + return null; + } + + if ($this->locks[$key]['expires'] <= Time::now()->getTimestamp()) { + unset($this->locks[$key]); + + return null; + } + + return $this->locks[$key]['owner']; + } + + public function clean(): void + { + $this->locks = []; + } +} diff --git a/system/Validation/Rules.php b/system/Validation/Rules.php index 7617b283f099..f2f55ec2736d 100644 --- a/system/Validation/Rules.php +++ b/system/Validation/Rules.php @@ -467,7 +467,7 @@ public function field_exists( ?string $field = null, ): bool { if (str_contains($field, '.')) { - return ArrayHelper::dotKeyExists($field, $data); + return ArrayHelper::dotHas($field, $data); } return array_key_exists($field, $data); diff --git a/system/Validation/StrictRules/FileRules.php b/system/Validation/StrictRules/FileRules.php index b6d4fdb673fd..4169a1b9f7e9 100644 --- a/system/Validation/StrictRules/FileRules.php +++ b/system/Validation/StrictRules/FileRules.php @@ -62,17 +62,14 @@ public function uploaded(?string $blank, string $name): bool return false; } - if (ENVIRONMENT === 'testing') { - if ($file->getError() !== 0) { - return false; - } - } else { - // Note: cannot unit test this; no way to over-ride ENVIRONMENT? - // @codeCoverageIgnoreStart - if (! $file->isValid()) { - return false; - } - // @codeCoverageIgnoreEnd + // In the testing env is_uploaded_file() always returns false + // (fixtures aren't real HTTP uploads), so check the error code directly. + $isValid = service('environment')->isTesting() + ? $file->getError() === UPLOAD_ERR_OK + : $file->isValid(); + + if (! $isValid) { + return false; } } diff --git a/system/Validation/StrictRules/Rules.php b/system/Validation/StrictRules/Rules.php index 6d8dce14588b..e8d533d25987 100644 --- a/system/Validation/StrictRules/Rules.php +++ b/system/Validation/StrictRules/Rules.php @@ -52,7 +52,7 @@ public function differs( } if (str_contains($field, '.')) { - if (! ArrayHelper::dotKeyExists($field, $data)) { + if (! ArrayHelper::dotHas($field, $data)) { return false; } } elseif (! array_key_exists($field, $data)) { @@ -245,7 +245,7 @@ public function matches( } if (str_contains($field, '.')) { - if (! ArrayHelper::dotKeyExists($field, $data)) { + if (! ArrayHelper::dotHas($field, $data)) { return false; } } elseif (! array_key_exists($field, $data)) { @@ -386,7 +386,7 @@ public function field_exists( ?string $field = null, ): bool { if (str_contains($field, '.')) { - return ArrayHelper::dotKeyExists($field, $data); + return ArrayHelper::dotHas($field, $data); } return array_key_exists($field, $data); diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 38210fbdb705..fa8522ac7799 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -21,6 +21,7 @@ use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\Input\ValidatedInput; use CodeIgniter\Validation\Exceptions\ValidationException; use CodeIgniter\View\RendererInterface; @@ -271,6 +272,14 @@ public function getValidated(): array return $this->validated; } + /** + * Returns the actual validated data as a typed input object. + */ + public function getValidatedInput(): ValidatedInput + { + return service('inputdatafactory')->createValidated($this->validated); + } + /** * Runs all of $rules against $field, until one fails, or * all of them have been processed. If one fails, it adds @@ -369,14 +378,16 @@ protected function processRules( $fieldForErrors = ($rule === 'field_exists') ? $originalField : $field; // @phpstan-ignore-next-line $error may be set by rule methods. - $this->errors[$fieldForErrors] = $error ?? $this->getErrorMessage( - ($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule, - $field, - $label, - $param, - (string) $value, - $originalField, - ); + $this->errors[$fieldForErrors] = $error !== null + ? $this->parseErrorMessage($error, $field, $label, $param, (string) $value) + : $this->getErrorMessage( + ($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule, + $field, + $label, + $param, + (string) $value, + $originalField, + ); return false; } @@ -933,13 +944,7 @@ protected function getErrorMessage( ?string $value = null, ?string $originalField = null, ): string { - $param ??= ''; - - $args = [ - 'field' => ($label === null || $label === '') ? $field : lang($label), - 'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param, - 'value' => $value ?? '', - ]; + $args = $this->buildErrorArgs($field, $label, $param, $value); // Check if custom message has been defined by user if (isset($this->customErrors[$field][$rule])) { @@ -955,6 +960,49 @@ protected function getErrorMessage( return lang('Validation.' . $rule, $args); } + /** + * Substitutes {field}, {param}, and {value} placeholders in an error message + * set directly by a rule method via the $error reference parameter. + * + * Uses simple string replacement rather than lang() to avoid ICU MessageFormatter + * warnings on unrecognised patterns and to leave any other {xyz} content untouched. + */ + private function parseErrorMessage( + string $message, + string $field, + ?string $label = null, + ?string $param = null, + ?string $value = null, + ): string { + $args = $this->buildErrorArgs($field, $label, $param, $value); + + return str_replace( + ['{field}', '{param}', '{value}'], + [$args['field'], $args['param'], $args['value']], + $message, + ); + } + + /** + * Builds the placeholder arguments array used for error message substitution. + * + * @return array{field: string, param: string, value: string} + */ + private function buildErrorArgs( + string $field, + ?string $label = null, + ?string $param = null, + ?string $value = null, + ): array { + $param ??= ''; + + return [ + 'field' => ($label === null || $label === '') ? $field : lang($label), + 'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param, + 'value' => $value ?? '', + ]; + } + /** * Split rules string by pipe operator. */ diff --git a/system/Validation/ValidationInterface.php b/system/Validation/ValidationInterface.php index 836516d561b8..6a0f3e2e3d02 100644 --- a/system/Validation/ValidationInterface.php +++ b/system/Validation/ValidationInterface.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\Input\ValidatedInput; /** * Expected behavior of a validator @@ -162,4 +163,9 @@ public function showError(string $field, string $template = 'single'): string; * Returns the actual validated data. */ public function getValidated(): array; + + /** + * Returns the actual validated data as a typed input object. + */ + public function getValidatedInput(): ValidatedInput; } diff --git a/system/bootstrap.php b/system/bootstrap.php deleted file mode 100644 index c0b021494737..000000000000 --- a/system/bootstrap.php +++ /dev/null @@ -1,163 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -/** - * --------------------------------------------------------------- - * This file cannot be used. The code has moved to Boot.php. - * --------------------------------------------------------------- - */ - -use CodeIgniter\Exceptions\FrameworkException; -use Config\Autoload; -use Config\Modules; -use Config\Paths; -use Config\Services; - -header('HTTP/1.1 503 Service Unavailable.', true, 503); - -$message = 'This "system/bootstrap.php" is no longer used. If you are seeing this error message, -the upgrade is not complete. Please refer to the upgrade guide and complete the upgrade. -See https://codeigniter4.github.io/userguide/installation/upgrade_450.html' . PHP_EOL; -echo $message; - -/* - * --------------------------------------------------------------- - * SETUP OUR PATH CONSTANTS - * --------------------------------------------------------------- - * - * The path constants provide convenient access to the folders - * throughout the application. We have to setup them up here - * so they are available in the config files that are loaded. - */ - -/** @var Paths $paths */ - -// The path to the application directory. -if (! defined('APPPATH')) { - define('APPPATH', realpath(rtrim($paths->appDirectory, '\\/ ')) . DIRECTORY_SEPARATOR); -} - -// The path to the project root directory. Just above APPPATH. -if (! defined('ROOTPATH')) { - define('ROOTPATH', realpath(APPPATH . '../') . DIRECTORY_SEPARATOR); -} - -// The path to the system directory. -if (! defined('SYSTEMPATH')) { - define('SYSTEMPATH', realpath(rtrim($paths->systemDirectory, '\\/ ')) . DIRECTORY_SEPARATOR); -} - -// The path to the writable directory. -if (! defined('WRITEPATH')) { - define('WRITEPATH', realpath(rtrim($paths->writableDirectory, '\\/ ')) . DIRECTORY_SEPARATOR); -} - -// The path to the tests directory -if (! defined('TESTPATH')) { - define('TESTPATH', realpath(rtrim($paths->testsDirectory, '\\/ ')) . DIRECTORY_SEPARATOR); -} - -/* - * --------------------------------------------------------------- - * GRAB OUR CONSTANTS - * --------------------------------------------------------------- - */ - -if (! defined('APP_NAMESPACE')) { - require_once APPPATH . 'Config/Constants.php'; -} - -/* - * --------------------------------------------------------------- - * LOAD COMMON FUNCTIONS - * --------------------------------------------------------------- - */ - -// Require app/Common.php file if exists. -if (is_file(APPPATH . 'Common.php')) { - require_once APPPATH . 'Common.php'; -} - -// Require system/Common.php -require_once SYSTEMPATH . 'Common.php'; - -/* - * --------------------------------------------------------------- - * LOAD OUR AUTOLOADER - * --------------------------------------------------------------- - * - * The autoloader allows all of the pieces to work together in the - * framework. We have to load it here, though, so that the config - * files can use the path constants. - */ - -if (! class_exists(Autoload::class, false)) { - require_once SYSTEMPATH . 'Config/AutoloadConfig.php'; - require_once APPPATH . 'Config/Autoload.php'; - require_once SYSTEMPATH . 'Modules/Modules.php'; - require_once APPPATH . 'Config/Modules.php'; -} - -require_once SYSTEMPATH . 'Autoloader/Autoloader.php'; -require_once SYSTEMPATH . 'Config/BaseService.php'; -require_once SYSTEMPATH . 'Config/Services.php'; -require_once APPPATH . 'Config/Services.php'; - -// Initialize and register the loader with the SPL autoloader stack. -Services::autoloader()->initialize(new Autoload(), new Modules())->register(); -Services::autoloader()->loadHelpers(); - -/* - * --------------------------------------------------------------- - * SET EXCEPTION AND ERROR HANDLERS - * --------------------------------------------------------------- - */ - -Services::exceptions()->initialize(); - -/* - * --------------------------------------------------------------- - * CHECK SYSTEM FOR MISSING REQUIRED PHP EXTENSIONS - * --------------------------------------------------------------- - */ - -// Run this check for manual installations -if (! is_file(COMPOSER_PATH)) { - $missingExtensions = []; - - foreach ([ - 'intl', - 'json', - 'mbstring', - ] as $extension) { - if (! extension_loaded($extension)) { - $missingExtensions[] = $extension; - } - } - - if ($missingExtensions !== []) { - throw FrameworkException::forMissingExtension(implode(', ', $missingExtensions)); - } - - unset($missingExtensions); -} - -/* - * --------------------------------------------------------------- - * INITIALIZE KINT - * --------------------------------------------------------------- - */ - -Services::autoloader()->initializeKint(CI_DEBUG); - -exit(1); diff --git a/tests/_support/API/ChildTransformer.php b/tests/_support/API/ChildTransformer.php index a226dad97608..56629932d518 100644 --- a/tests/_support/API/ChildTransformer.php +++ b/tests/_support/API/ChildTransformer.php @@ -21,6 +21,8 @@ */ class ChildTransformer extends BaseTransformer { + protected ?string $resourceType = 'child'; + public function toArray(mixed $resource): array { return [ diff --git a/tests/_support/API/ParentTransformer.php b/tests/_support/API/ParentTransformer.php index 803b4dbb353f..4c5331e14f06 100644 --- a/tests/_support/API/ParentTransformer.php +++ b/tests/_support/API/ParentTransformer.php @@ -21,6 +21,8 @@ */ class ParentTransformer extends BaseTransformer { + protected ?string $resourceType = 'parent'; + public function toArray(mixed $resource): array { return [ diff --git a/tests/_support/Commands/AbstractInfo.php b/tests/_support/Commands/Legacy/AbstractInfo.php similarity index 92% rename from tests/_support/Commands/AbstractInfo.php rename to tests/_support/Commands/Legacy/AbstractInfo.php index 3195688db2ce..5e9a2808ec84 100644 --- a/tests/_support/Commands/AbstractInfo.php +++ b/tests/_support/Commands/Legacy/AbstractInfo.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; diff --git a/tests/_support/Commands/AppInfo.php b/tests/_support/Commands/Legacy/AppInfo.php similarity index 85% rename from tests/_support/Commands/AppInfo.php rename to tests/_support/Commands/Legacy/AppInfo.php index 94502e0706f5..ac5b11b6416d 100644 --- a/tests/_support/Commands/AppInfo.php +++ b/tests/_support/Commands/Legacy/AppInfo.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; @@ -32,26 +32,24 @@ public function run(array $params): int { CLI::write(sprintf('CodeIgniter Version: %s', CodeIgniter::CI_VERSION)); - return 0; + return EXIT_SUCCESS; } public function bomb(): int { try { CLI::color('test', 'white', 'Background'); + + return EXIT_SUCCESS; } catch (RuntimeException $e) { $this->showError($e); - return 1; + return EXIT_ERROR; } - - return 0; } public function helpMe(): int { - $this->call('help'); - - return 0; + return $this->call('help:legacy'); } } diff --git a/tests/_support/Commands/DestructiveCommand.php b/tests/_support/Commands/Legacy/DestructiveCommand.php similarity index 94% rename from tests/_support/Commands/DestructiveCommand.php rename to tests/_support/Commands/Legacy/DestructiveCommand.php index 723b880b0cd4..b7320a7e2563 100644 --- a/tests/_support/Commands/DestructiveCommand.php +++ b/tests/_support/Commands/Legacy/DestructiveCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use RuntimeException; diff --git a/tests/_support/Commands/Foobar.php b/tests/_support/Commands/Legacy/Foobar.php similarity index 100% rename from tests/_support/Commands/Foobar.php rename to tests/_support/Commands/Legacy/Foobar.php diff --git a/tests/_support/Commands/Legacy/HelpLegacyCommand.php b/tests/_support/Commands/Legacy/HelpLegacyCommand.php new file mode 100644 index 000000000000..b6673e01aaa0 --- /dev/null +++ b/tests/_support/Commands/Legacy/HelpLegacyCommand.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Legacy; + +use CodeIgniter\CLI\BaseCommand; + +/** + * Test fixture only. Exercises that a legacy `BaseCommand` can invoke a modern + * `AbstractCommand` via {@see \CodeIgniter\CLI\Commands::runCommand()}. Not a + * pattern to follow in application code — migrate legacy commands to + * `AbstractCommand` instead. + */ +final class HelpLegacyCommand extends BaseCommand +{ + protected $group = 'Fixtures'; + protected $name = 'help:legacy'; + protected $description = 'Legacy command to call the help command.'; + + public function run(array $params): int + { + return $this->commands->runCommand('help', [], []); + } +} diff --git a/tests/_support/Commands/InvalidCommand.php b/tests/_support/Commands/Legacy/InvalidCommand.php similarity index 76% rename from tests/_support/Commands/InvalidCommand.php rename to tests/_support/Commands/Legacy/InvalidCommand.php index 5604ba19fd36..8d6fadb80dfd 100644 --- a/tests/_support/Commands/InvalidCommand.php +++ b/tests/_support/Commands/Legacy/InvalidCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; @@ -26,11 +26,13 @@ class InvalidCommand extends BaseCommand public function __construct() { - throw new ReflectionException(); + throw new ReflectionException('This command is invalid and should not be instantiated.'); } - public function run(array $params): void + public function run(array $params): int { CLI::write('CI Version: ' . CLI::color(CodeIgniter::CI_VERSION, 'red')); + + return EXIT_SUCCESS; } } diff --git a/tests/_support/Commands/LanguageCommand.php b/tests/_support/Commands/Legacy/LanguageCommand.php similarity index 91% rename from tests/_support/Commands/LanguageCommand.php rename to tests/_support/Commands/Legacy/LanguageCommand.php index 17fba1881c94..fe9efe692493 100644 --- a/tests/_support/Commands/LanguageCommand.php +++ b/tests/_support/Commands/Legacy/LanguageCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\GeneratorTrait; @@ -29,7 +29,7 @@ class LanguageCommand extends BaseCommand '--sort' => 'Turn on/off the sortImports flag.', ]; - public function run(array $params): void + public function run(array $params): int { $this->setHasClassName(false); $params[0] = 'Foobar'; @@ -42,6 +42,8 @@ public function run(array $params): void $this->setSortImports($sort); $this->generateClass($params); + + return EXIT_SUCCESS; } protected function prepare(string $class): string diff --git a/tests/_support/Commands/Legacy/NullReturningCommand.php b/tests/_support/Commands/Legacy/NullReturningCommand.php new file mode 100644 index 000000000000..e1cdca8dfbd8 --- /dev/null +++ b/tests/_support/Commands/Legacy/NullReturningCommand.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Legacy; + +use CodeIgniter\CLI\BaseCommand; + +/** + * @internal + */ +final class NullReturningCommand extends BaseCommand +{ + protected $group = 'Fixtures'; + protected $name = 'null:return'; + protected $description = 'A command that returns null.'; + + public function run(array $params) + { + return null; + } +} diff --git a/tests/_support/Commands/SignalCommand.php b/tests/_support/Commands/Legacy/SignalCommand.php similarity index 98% rename from tests/_support/Commands/SignalCommand.php rename to tests/_support/Commands/Legacy/SignalCommand.php index 219f76a48296..490efe6e1509 100644 --- a/tests/_support/Commands/SignalCommand.php +++ b/tests/_support/Commands/Legacy/SignalCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\SignalTrait; diff --git a/tests/_support/Commands/SignalCommandNoPcntl.php b/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php similarity index 93% rename from tests/_support/Commands/SignalCommandNoPcntl.php rename to tests/_support/Commands/Legacy/SignalCommandNoPcntl.php index 2903c714afb3..43a5d0f9e65d 100644 --- a/tests/_support/Commands/SignalCommandNoPcntl.php +++ b/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; /** * Mock command that simulates missing PCNTL extension diff --git a/tests/_support/Commands/SignalCommandNoPosix.php b/tests/_support/Commands/Legacy/SignalCommandNoPosix.php similarity index 93% rename from tests/_support/Commands/SignalCommandNoPosix.php rename to tests/_support/Commands/Legacy/SignalCommandNoPosix.php index 02ce3a1fcefd..caafbd5db2aa 100644 --- a/tests/_support/Commands/SignalCommandNoPosix.php +++ b/tests/_support/Commands/Legacy/SignalCommandNoPosix.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; /** * Mock command that simulates missing POSIX extension diff --git a/tests/_support/Commands/Unsuffixable.php b/tests/_support/Commands/Legacy/Unsuffixable.php similarity index 93% rename from tests/_support/Commands/Unsuffixable.php rename to tests/_support/Commands/Legacy/Unsuffixable.php index dc0cc9850980..6fed8844cf43 100644 --- a/tests/_support/Commands/Unsuffixable.php +++ b/tests/_support/Commands/Legacy/Unsuffixable.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\GeneratorTrait; @@ -67,7 +67,7 @@ class Unsuffixable extends BaseCommand /** * Actually execute a command. */ - public function run(array $params): void + public function run(array $params): int { $this->component = 'Command'; $this->directory = 'Commands'; @@ -75,6 +75,8 @@ public function run(array $params): void $this->setEnabledSuffixing(false); $this->generateClass($params); + + return EXIT_SUCCESS; } protected function prepare(string $class): string diff --git a/tests/_support/Commands/Modern/AliasedCommand.php b/tests/_support/Commands/Modern/AliasedCommand.php new file mode 100644 index 000000000000..9d1390f6cfc3 --- /dev/null +++ b/tests/_support/Commands/Modern/AliasedCommand.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; + +#[Command( + name: 'fixture:aliased', + description: 'Fixture command exercising command aliases.', + group: 'Fixtures', + aliases: ['fixture:alias', 'fa'], +)] +final class AliasedCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + CLI::write('Ran fixture:aliased.'); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/AppAboutCommand.php b/tests/_support/Commands/Modern/AppAboutCommand.php new file mode 100644 index 000000000000..7603f367ebc4 --- /dev/null +++ b/tests/_support/Commands/Modern/AppAboutCommand.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Exceptions\RuntimeException; + +#[Command(name: 'app:about', description: 'Displays basic application information.', group: 'Fixtures')] +final class AppAboutCommand extends AbstractCommand +{ + protected function configure(): void + { + $this + ->addArgument(new Argument(name: 'required', description: 'Unused required argument.', required: true)) + ->addArgument(new Argument(name: 'optional', description: 'Unused optional argument.', default: 'val')) + ->addArgument(new Argument(name: 'array', description: 'Unused array argument.', isArray: true, default: ['a', 'b'])) + ->addOption(new Option(name: 'foo', shortcut: 'f', description: 'Option that requires a value.', requiresValue: true, default: 'qux')) + ->addOption(new Option(name: 'bar', shortcut: 'a', description: 'Option that optionally accepts a value.', acceptsValue: true)) + ->addOption(new Option(name: 'baz', shortcut: 'b', description: 'Option that allows multiple values.', requiresValue: true, isArray: true, default: ['a'])) + ->addOption(new Option(name: 'quux', description: 'Negatable option.', negatable: true, default: false)) + ->addUsage('app:about required-value'); + } + + protected function execute(array $arguments, array $options): int + { + CLI::write(sprintf('CodeIgniter Version: %s', CLI::color(CodeIgniter::CI_VERSION, 'red'))); + + return EXIT_SUCCESS; + } + + public function bomb(): int + { + try { + CLI::color('test', 'white', 'Background'); + + return EXIT_SUCCESS; + } catch (RuntimeException $e) { + $this->renderThrowable($e); + + return EXIT_ERROR; + } + } + + public function helpMe(): int + { + return $this->call('help'); + } + + public function helpMeSilently(): int + { + return $this->callSilently('help'); + } + + public function callUnknownSilently(): int + { + return $this->callSilently('does:not:exist'); + } + + /** + * @param array|string|null>|null $options + */ + public function callHasUnboundOption(string $name, ?array $options = null): bool + { + return $this->hasUnboundOption($name, $options); + } + + /** + * @param array|string|null>|null $options + * + * @return list|string|null + */ + public function callGetUnboundOption(string $name, ?array $options = null): array|string|null + { + return $this->getUnboundOption($name, $options); + } + + /** + * @return list + */ + public function callGetUnboundArguments(): array + { + return $this->getUnboundArguments(); + } + + public function callGetUnboundArgument(int $index): string + { + return $this->getUnboundArgument($index); + } + + /** + * @return array|string|null> + */ + public function callGetUnboundOptions(): array + { + return $this->getUnboundOptions(); + } + + /** + * @return array|string> + */ + public function callGetValidatedArguments(): array + { + return $this->getValidatedArguments(); + } + + /** + * @return list|string + */ + public function callGetValidatedArgument(string $name): array|string + { + return $this->getValidatedArgument($name); + } + + /** + * @return array|string|null> + */ + public function callGetValidatedOptions(): array + { + return $this->getValidatedOptions(); + } + + /** + * @return bool|list|string|null + */ + public function callGetValidatedOption(string $name): array|bool|string|null + { + return $this->getValidatedOption($name); + } +} diff --git a/tests/_support/Commands/Modern/InteractFixtureCommand.php b/tests/_support/Commands/Modern/InteractFixtureCommand.php new file mode 100644 index 000000000000..821bee24deb4 --- /dev/null +++ b/tests/_support/Commands/Modern/InteractFixtureCommand.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; + +#[Command(name: 'test:interact', description: 'Fixture that mutates arguments and options in interact().', group: 'Fixtures')] +final class InteractFixtureCommand extends AbstractCommand +{ + /** + * @var array|string> + */ + public array $executedArguments = []; + + /** + * @var array|string|null> + */ + public array $executedOptions = []; + + protected function configure(): void + { + $this + ->addArgument(new Argument(name: 'name', default: 'anonymous')) + ->addOption(new Option(name: 'force')); + } + + protected function interact(array &$arguments, array &$options): void + { + // Supply a positional argument the caller omitted. + if ($arguments === []) { + $arguments[] = 'from-interact'; + } + + // Simulate the `--force` flag being passed so execute() sees it bound to `true`. + $options['force'] = null; + } + + protected function execute(array $arguments, array $options): int + { + $this->executedArguments = $arguments; + $this->executedOptions = $options; + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/InteractiveStateProbeCommand.php b/tests/_support/Commands/Modern/InteractiveStateProbeCommand.php new file mode 100644 index 000000000000..78d69fdb26e8 --- /dev/null +++ b/tests/_support/Commands/Modern/InteractiveStateProbeCommand.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'test:probe', description: 'Fixture that records its interactive state so the caller can assert on it.', group: 'Fixtures')] +final class InteractiveStateProbeCommand extends AbstractCommand +{ + /** + * Records whether `interact()` fired during the last run. This is a side-channel + * for asserting on a child fixture created anonymously by `Commands::runCommand()`. + */ + public static bool $interactCalled = false; + + public static ?bool $observedInteractive = null; + + public static function reset(): void + { + self::$interactCalled = false; + self::$observedInteractive = null; + } + + protected function interact(array &$arguments, array &$options): void + { + self::$interactCalled = true; + } + + protected function execute(array $arguments, array $options): int + { + self::$observedInteractive = $this->isInteractive(); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php b/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php new file mode 100644 index 000000000000..ff182300a0e9 --- /dev/null +++ b/tests/_support/Commands/Modern/ParentCallsInteractFixtureCommand.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'test:parent-interact', description: 'Fixture that delegates to test:probe via call().', group: 'Fixtures')] +final class ParentCallsInteractFixtureCommand extends AbstractCommand +{ + /** + * Forwarded verbatim as the `$noInteractionOverride` argument of `call()`. + * `null` leaves the default propagation behavior in place. + */ + public ?bool $childNoInteractionOverride = null; + + /** + * Forwarded verbatim as the `$options` argument of `call()`. Lets tests + * exercise the resolver's caller-provided-flag code paths. + * + * @var array|string|null> + */ + public array $childOptions = []; + + public bool $useCallSilently = false; + + protected function execute(array $arguments, array $options): int + { + if ($this->useCallSilently) { + return $this->callSilently('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride); + } + + return $this->call('test:probe', options: $this->childOptions, noInteractionOverride: $this->childNoInteractionOverride); + } +} diff --git a/tests/_support/Commands/Modern/TestFixtureCommand.php b/tests/_support/Commands/Modern/TestFixtureCommand.php new file mode 100644 index 000000000000..e442790db72a --- /dev/null +++ b/tests/_support/Commands/Modern/TestFixtureCommand.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\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'test:fixture', group: 'Fixtures', description: 'A command used as a fixture for testing purposes.')] +final class TestFixtureCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/UnavailableFixtureCommand.php b/tests/_support/Commands/Modern/UnavailableFixtureCommand.php new file mode 100644 index 000000000000..5ac64584f7cc --- /dev/null +++ b/tests/_support/Commands/Modern/UnavailableFixtureCommand.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'test:unavailable', description: 'Fixture command to test runtime availability checks.', group: 'Tests')] +final class UnavailableFixtureCommand extends AbstractCommand +{ + public static bool $initializeCalled = false; + public static bool $interactCalled = false; + public static bool $executeCalled = false; + public static bool $available = true; + + public static function reset(): void + { + self::$initializeCalled = false; + self::$interactCalled = false; + self::$executeCalled = false; + self::$available = true; + } + + protected function isAvailable(): bool + { + return self::$available; + } + + protected function initialize(array &$arguments, array &$options): void + { + self::$initializeCalled = true; + } + + protected function interact(array &$arguments, array &$options): void + { + self::$interactCalled = true; + } + + protected function execute(array $arguments, array $options): int + { + self::$executeCalled = true; + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Config/MergeOrderRegistrar.php b/tests/_support/Config/MergeOrderRegistrar.php new file mode 100644 index 000000000000..bfd6f1efc87c --- /dev/null +++ b/tests/_support/Config/MergeOrderRegistrar.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\Config; + +use CodeIgniter\Config\Merge; + +/** + * Registrar exercising the ordering directives (prepend/before/after) through + * the real registrar flow, including nesting inside byKey() for a Filters-style + * globals list. + */ +class MergeOrderRegistrar +{ + public static function MergeRegistrarConfig(): array + { + return [ + // Order a filter relative to an existing one in a nested list. + 'globals' => Merge::byKey([ + 'before' => Merge::after('csrf', ['auth']), + 'after' => Merge::prepend(['honeypot']), + ]), + // Property-root list ordering. + 'list' => Merge::before('a', ['z']), + ]; + } +} diff --git a/tests/_support/Config/MergePlainRegistrar.php b/tests/_support/Config/MergePlainRegistrar.php new file mode 100644 index 000000000000..2f8826893276 --- /dev/null +++ b/tests/_support/Config/MergePlainRegistrar.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +/** + * Plain-array registrar (no directives) used to assert the legacy shallow + * merge behavior is unchanged — nested siblings are dropped. + */ +class MergePlainRegistrar +{ + public static function MergeRegistrarConfig(): array + { + return [ + 'arrayNested' => [ + 'key2' => ['val4' => 'subVal4'], + ], + ]; + } +} diff --git a/tests/_support/Config/MergeRegistrar.php b/tests/_support/Config/MergeRegistrar.php new file mode 100644 index 000000000000..04acf09b745c --- /dev/null +++ b/tests/_support/Config/MergeRegistrar.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +use CodeIgniter\Config\Merge; + +/** + * Registrar exercising the Merge directives against MergeRegistrarConfig. + */ +class MergeRegistrar +{ + public static function MergeRegistrarConfig(): array + { + return [ + // Example A — deep-merge a nested subtree, preserving siblings. + 'arrayNested' => Merge::byKey([ + 'key2' => ['val4' => 'subVal4'], + ]), + // Example B — append to a list nested under a string key. + 'matrix' => Merge::byKey([ + 'superadmin' => ['shippinglabel-logos.*'], + ]), + // Example C — nested directives inside byKey(). + 'globals' => Merge::byKey([ + 'before' => Merge::append(['blogFilter']), + 'after' => Merge::replace([]), + ]), + // Scalar replace. + 'handler' => Merge::replace('redis'), + // Property-root append. + 'list' => Merge::append(['c']), + ]; + } +} diff --git a/tests/_support/Config/MergeRegistrarA.php b/tests/_support/Config/MergeRegistrarA.php new file mode 100644 index 000000000000..4a808445a13a --- /dev/null +++ b/tests/_support/Config/MergeRegistrarA.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +use CodeIgniter\Config\Merge; + +/** + * First of two registrars touching the same properties, used to assert that + * accumulation/replacement follows registrar (discovery) order. + */ +class MergeRegistrarA +{ + public static function MergeRegistrarConfig(): array + { + return [ + 'list' => Merge::append(['x']), + 'handler' => Merge::replace('redis'), + ]; + } +} diff --git a/tests/_support/Config/MergeRegistrarB.php b/tests/_support/Config/MergeRegistrarB.php new file mode 100644 index 000000000000..364eb59b5a8d --- /dev/null +++ b/tests/_support/Config/MergeRegistrarB.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Config; + +use CodeIgniter\Config\Merge; + +/** + * Second of two registrars touching the same properties, used to assert that + * accumulation/replacement follows registrar (discovery) order. + */ +class MergeRegistrarB +{ + public static function MergeRegistrarConfig(): array + { + return [ + 'list' => Merge::append(['y']), + 'handler' => Merge::replace('memcached'), + ]; + } +} diff --git a/tests/_support/Config/Registrar.php b/tests/_support/Config/Registrar.php index 058fec440b55..d376a177840f 100644 --- a/tests/_support/Config/Registrar.php +++ b/tests/_support/Config/Registrar.php @@ -60,7 +60,6 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], 'port' => 5432, ], @@ -79,7 +78,6 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], 'port' => 3306, 'foreignKeys' => true, @@ -100,7 +98,6 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], 'port' => 1433, ], @@ -119,7 +116,6 @@ class Registrar 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, // @todo 4.7.0 to remove in v4.8.0 'failover' => [], ], ]; diff --git a/tests/_support/Config/Services.php b/tests/_support/Config/Services.php index 3cb7c53388c3..ccd683e6b5cf 100644 --- a/tests/_support/Config/Services.php +++ b/tests/_support/Config/Services.php @@ -45,7 +45,7 @@ public static function uri(?string $uri = null, bool $getShared = true): URI if ($uri === null) { $appConfig = config(App::class); - $factory = new SiteURIFactory($appConfig, Services::superglobals()); + $factory = new SiteURIFactory($appConfig, static::superglobals()); return $factory->createFromGlobals(); } diff --git a/tests/_support/Controllers/FormRequestController.php b/tests/_support/Controllers/FormRequestController.php new file mode 100644 index 000000000000..bdc3670a8c6a --- /dev/null +++ b/tests/_support/Controllers/FormRequestController.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Controllers; + +use CodeIgniter\Controller; +use Tests\Support\HTTP\Requests\UnauthorizedFormRequest; +use Tests\Support\HTTP\Requests\ValidPostFormRequest; + +/** + * Controller used in FormRequest integration tests. + */ +class FormRequestController extends Controller +{ + /** + * Optional trailing param after a FormRequest - verifies that the optional + * param gets its default value when the corresponding URI segment is absent. + */ + public function index(string $id, ValidPostFormRequest $request, string $format = 'json'): string + { + return json_encode(['id' => $id, 'format' => $format, 'data' => $request->getValidated()]); + } + + /** + * Receives only a FormRequest (no route params). + */ + public function store(ValidPostFormRequest $request): string + { + return json_encode($request->getValidated()); + } + + /** + * Receives a route param alongside a FormRequest. + */ + public function update(string $id, ValidPostFormRequest $request): string + { + return json_encode(['id' => $id, 'data' => $request->getValidated()]); + } + + /** + * No FormRequest - verifies BC with plain route params. + */ + public function show(string $id): string + { + return 'item-' . $id; + } + + /** + * Variadic route params alongside a FormRequest - verifies that all extra + * URI segments are collected into the variadic array. + */ + public function search(ValidPostFormRequest $request, string ...$tags): string + { + return json_encode(['tags' => $tags, 'data' => $request->getValidated()]); + } + + /** + * Uses an always-unauthorized FormRequest. + */ + public function restricted(UnauthorizedFormRequest $request): string + { + return 'should-not-reach'; + } +} diff --git a/tests/_support/Duplicates/DuplicateLegacy.php b/tests/_support/Duplicates/DuplicateLegacy.php new file mode 100644 index 000000000000..0837d11385d3 --- /dev/null +++ b/tests/_support/Duplicates/DuplicateLegacy.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Duplicates; + +use CodeIgniter\CLI\BaseCommand; + +/** + * Lives outside any `Commands/` directory so discovery does not pick it up + * automatically. Tests inject this via a mocked FileLocator. + * + * @internal + */ +final class DuplicateLegacy extends BaseCommand +{ + protected $group = 'Duplicates'; + protected $name = 'dup:test'; + protected $description = 'Legacy fixture that collides with a modern command of the same name.'; + + public function run(array $params): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Duplicates/DuplicateModern.php b/tests/_support/Duplicates/DuplicateModern.php new file mode 100644 index 000000000000..082e82cb4a9b --- /dev/null +++ b/tests/_support/Duplicates/DuplicateModern.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\Duplicates; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +/** + * Lives outside any `Commands/` directory so discovery does not pick it up + * automatically. Tests inject this via a mocked FileLocator. + * + * @internal + */ +#[Command( + name: 'dup:test', + description: 'Modern fixture that collides with a legacy command of the same name.', + group: 'Duplicates', + aliases: ['dup:alias'], +)] +final class DuplicateModern extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/HTTP/Requests/UnauthorizedFormRequest.php b/tests/_support/HTTP/Requests/UnauthorizedFormRequest.php new file mode 100644 index 000000000000..24dc8a6a092c --- /dev/null +++ b/tests/_support/HTTP/Requests/UnauthorizedFormRequest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\HTTP\Requests; + +use CodeIgniter\HTTP\FormRequest; + +/** + * A FormRequest that always denies authorization. + */ +class UnauthorizedFormRequest extends FormRequest +{ + public function rules(): array + { + return ['title' => 'required']; + } + + public function isAuthorized(): bool + { + return false; + } +} diff --git a/tests/_support/HTTP/Requests/ValidPostFormRequest.php b/tests/_support/HTTP/Requests/ValidPostFormRequest.php new file mode 100644 index 000000000000..636f296b088d --- /dev/null +++ b/tests/_support/HTTP/Requests/ValidPostFormRequest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\HTTP\Requests; + +use CodeIgniter\HTTP\FormRequest; + +/** + * A FormRequest used in integration tests requiring title + body. + * + * @property-read string $body + * @property-read string $title + */ +class ValidPostFormRequest extends FormRequest +{ + public function rules(): array + { + return [ + 'title' => 'required|min_length[3]', + 'body' => 'required', + ]; + } +} diff --git a/tests/_support/Images/ci-logo.avif b/tests/_support/Images/ci-logo.avif new file mode 100644 index 000000000000..9741b69ab0c9 Binary files /dev/null and b/tests/_support/Images/ci-logo.avif differ diff --git a/tests/_support/InvalidCommands/AliasClashCommand.php b/tests/_support/InvalidCommands/AliasClashCommand.php new file mode 100644 index 000000000000..2c16e74553ac --- /dev/null +++ b/tests/_support/InvalidCommands/AliasClashCommand.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\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'alias:source', description: 'Declares an alias used for collision tests.', group: 'Fixtures', aliases: ['alias:target'])] +final class AliasClashCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/AliasSecondClashCommand.php b/tests/_support/InvalidCommands/AliasSecondClashCommand.php new file mode 100644 index 000000000000..f04b2ae242c3 --- /dev/null +++ b/tests/_support/InvalidCommands/AliasSecondClashCommand.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\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'alias:source-two', description: 'Declares an alias already used by another command.', group: 'Fixtures', aliases: ['alias:target'])] +final class AliasSecondClashCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/AliasTargetCommand.php b/tests/_support/InvalidCommands/AliasTargetCommand.php new file mode 100644 index 000000000000..aa0021de6eb6 --- /dev/null +++ b/tests/_support/InvalidCommands/AliasTargetCommand.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\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'alias:target', description: 'Whose name an alias collides with.', group: 'Fixtures')] +final class AliasTargetCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/EmptyCommandName.php b/tests/_support/InvalidCommands/EmptyCommandName.php new file mode 100644 index 000000000000..1deb18614c3e --- /dev/null +++ b/tests/_support/InvalidCommands/EmptyCommandName.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\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command('')] +final class EmptyCommandName extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/NoAttributeCommand.php b/tests/_support/InvalidCommands/NoAttributeCommand.php new file mode 100644 index 000000000000..0d0fcd6ec09b --- /dev/null +++ b/tests/_support/InvalidCommands/NoAttributeCommand.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; + +final class NoAttributeCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Language/en/Core.php b/tests/_support/Language/en/Core.php index 7b3355cf311c..bbbd6657a330 100644 --- a/tests/_support/Language/en/Core.php +++ b/tests/_support/Language/en/Core.php @@ -12,6 +12,6 @@ */ return [ - 'missingExtension' => '{0} extension could not be found.', - 'bazillion' => 'billions and billions', // adds a new setting + 'invalidFile' => 'The file provided is invalid.', // replacement + 'bazillion' => 'billions and billions', // new setting ]; diff --git a/tests/_support/Log/Handlers/TestHandler.php b/tests/_support/Log/Handlers/TestHandler.php index 025943ffe588..9eaa3ef1ab15 100644 --- a/tests/_support/Log/Handlers/TestHandler.php +++ b/tests/_support/Log/Handlers/TestHandler.php @@ -31,6 +31,13 @@ class TestHandler extends FileHandler */ protected static $logs = []; + /** + * Local storage for log contexts. + * + * @var array> + */ + protected static array $contexts = []; + protected string $destination; /** @@ -45,7 +52,8 @@ public function __construct(array $config) $this->handles = $config['handles'] ?? []; $this->destination = $this->path . 'log-' . Time::now()->format('Y-m-d') . '.' . $this->fileExtension; - self::$logs = []; + self::$logs = []; + self::$contexts = []; } /** @@ -54,14 +62,16 @@ public function __construct(array $config) * will stop. Any handlers that have not run, yet, will not * be run. * - * @param string $level - * @param string $message + * @param string $level + * @param string $message + * @param array $context */ - public function handle($level, $message): bool + public function handle($level, $message, array $context = []): bool { $date = Time::now()->format($this->dateFormat); - self::$logs[] = strtoupper($level) . ' - ' . $date . ' --> ' . $message; + self::$logs[] = strtoupper($level) . ' - ' . $date . ' --> ' . $message; + self::$contexts[] = $context; return true; } @@ -70,4 +80,12 @@ public static function getLogs() { return self::$logs; } + + /** + * @return array> + */ + public static function getContexts(): array + { + return self::$contexts; + } } diff --git a/tests/_support/Mock/MockPreparedQuery.php b/tests/_support/Mock/MockPreparedQuery.php index c31dc266d84d..93c29ea73247 100644 --- a/tests/_support/Mock/MockPreparedQuery.php +++ b/tests/_support/Mock/MockPreparedQuery.php @@ -14,6 +14,7 @@ namespace Tests\Support\Mock; use CodeIgniter\Database\BasePreparedQuery; +use Throwable; /** * @internal @@ -22,7 +23,8 @@ */ final class MockPreparedQuery extends BasePreparedQuery { - public string $preparedSql = ''; + public string $preparedSql = ''; + public ?Throwable $thrownException = null; /** * @param array $options @@ -39,6 +41,10 @@ public function _prepare(string $sql, array $options = []): self */ public function _execute(array $data): bool { + if ($this->thrownException instanceof Throwable) { + throw $this->thrownException; + } + return true; } diff --git a/tests/_support/Validation/TestRules.php b/tests/_support/Validation/TestRules.php index cb6cde9d4584..a5219f8d5f01 100644 --- a/tests/_support/Validation/TestRules.php +++ b/tests/_support/Validation/TestRules.php @@ -25,6 +25,16 @@ public function customError(string $str, ?string &$error = null) return false; } + /** + * @param-out string $error + */ + public function custom_error_with_param(mixed $str, string $param, array $data, ?string &$error = null, string $field = ''): bool + { + $error = 'The {field} must be one of: {param}. Got: {value}'; + + return false; + } + public function check_object_rule(object $value, ?string $fields, array $data = []) { $find = false; diff --git a/tests/_support/_command/AppAboutCommand.php b/tests/_support/_command/AppAboutCommand.php new file mode 100644 index 000000000000..0efe65be8b77 --- /dev/null +++ b/tests/_support/_command/AppAboutCommand.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\Commands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; + +#[Command(name: 'app:about', description: 'This is testing to override `app:about` command.', group: 'App')] +final class AppAboutCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->addArgument(new Argument(name: 'unused', description: 'This argument is not used.', required: true)); + } + + protected function execute(array $arguments, array $options): int + { + CLI::write('This is ' . self::class); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/_command/ListCommands.php b/tests/_support/_command/AppInfo.php similarity index 80% rename from tests/_support/_command/ListCommands.php rename to tests/_support/_command/AppInfo.php index 5b73548d89de..775c6871d948 100644 --- a/tests/_support/_command/ListCommands.php +++ b/tests/_support/_command/AppInfo.php @@ -19,7 +19,7 @@ /** * @internal */ -final class ListCommands extends BaseCommand +final class AppInfo extends BaseCommand { /** * @var string @@ -29,17 +29,17 @@ final class ListCommands extends BaseCommand /** * @var string */ - protected $name = 'list'; + protected $name = 'app:info'; /** * @var string */ - protected $description = 'This is testing to override `list` command.'; + protected $description = 'This is testing to override `app:info` command.'; /** * @var string */ - protected $usage = 'list'; + protected $usage = 'app:info'; /** * Displays the help for the spark cli script itself. diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index f5d33491a655..6da0f1d21078 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -120,7 +120,7 @@ private function createRequestAndResponse(string $routePath = '', array $userHea null, new UserAgent(), ); - $this->response = new MockResponse($config); + $this->response = new MockResponse(); } $headers = array_merge(['Accept' => 'text/html'], $userHeaders); @@ -633,7 +633,7 @@ public function testFormatByRequestNegotiateIfFormatIsNotJsonOrXML(): void $this->createCookieConfig(); $request = new MockIncomingRequest($config, new SiteURI($config), null, new UserAgent()); - $response = new MockResponse($config); + $response = new MockResponse(); $controller = new class ($request, $response) { use ResponseTrait; diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php index 11f37f0fd859..52793c4c00cd 100644 --- a/tests/system/API/TransformerTest.php +++ b/tests/system/API/TransformerTest.php @@ -801,4 +801,125 @@ public function testBareNestedTransformerStillUsedByChildTransformerDirectly(): $this->assertSame(['child_id' => 42], $result); } + + public function testSparseFieldsetScopesNestedChildByType(): void + { + // ?fields[child]=child_id must scope the nested ChildTransformer + // ($resourceType = 'child') automatically, without any explicit request. + $request = $this->createMockRequest('include=children&fields[child]=child_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'children' => ['child_id' => 99], // 'status' dropped by fields[child] + ], $result); + } + + public function testSparseFieldsetForOneTypeLeavesOthersUntouched(): void + { + // ?fields[parent]=parent_id scopes only the root; the child is full. + $request = $this->createMockRequest('include=children&fields[parent]=parent_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'children' => ['child_id' => 99, 'status' => 'transformed'], + ], $result); + } + + public function testSparseFieldsetsScopeRootAndChildIndependently(): void + { + // The headline case: each type gets its own fieldset in one request. + $request = $this->createMockRequest('include=children&fields[parent]=parent_id&fields[child]=child_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'children' => ['child_id' => 99], + ], $result); + } + + public function testFlatFieldsDoesNotScopeTypedNestedChild(): void + { + // A flat ?fields= belongs to the root only and must not leak into a + // typed nested child. + $request = $this->createMockRequest('include=children&fields=parent_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'children' => ['child_id' => 99, 'status' => 'transformed'], + ], $result); + } + + public function testUnknownSparseFieldsetIsIgnored(): void + { + // A fieldset for a type not present in the response is simply ignored; + // every transformer returns all of its fields. + $request = $this->createMockRequest('include=children&fields[unknown]=id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'children' => ['child_id' => 99, 'status' => 'transformed'], + ], $result); + } + + public function testSparseFieldsetAppliesToRootWhenTypeMatches(): void + { + // ?fields[child]=child_id on a root ChildTransformer ($resourceType = 'child'). + $request = $this->createMockRequest('fields[child]=child_id'); + Services::injectMock('request', $request); + + $transformer = new ChildTransformer($request); + + $result = $transformer->transform(['id' => 42]); + + $this->assertSame(['child_id' => 42], $result); + } + + public function testSparseFieldsetRespectsAllowedFieldsWhitelist(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.invalidFields', ['secret'])); + + $request = $this->createMockRequest('fields[widget]=id,secret'); + Services::injectMock('request', $request); + + $transformer = new class ($request) extends BaseTransformer { + protected ?string $resourceType = 'widget'; + + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function getAllowedFields(): array + { + return ['id', 'name']; + } + }; + + $transformer->transform(['id' => 1, 'name' => 'Test', 'secret' => 'x']); + } } diff --git a/tests/system/AutoReview/FrameworkCodeTest.php b/tests/system/AutoReview/FrameworkCodeTest.php index de515fffebf6..6473c481bc67 100644 --- a/tests/system/AutoReview/FrameworkCodeTest.php +++ b/tests/system/AutoReview/FrameworkCodeTest.php @@ -30,10 +30,22 @@ final class FrameworkCodeTest extends TestCase { /** - * Cache of discovered test class names. + * Cache of source filenames. + * + * @var list + */ + private static array $sourceFiles = []; + + /** + * Cache of test class names. + * + * @var list */ private static array $testClasses = []; + /** + * @var list + */ private static array $recognizedGroupAttributeNames = [ 'AutoReview', 'CacheLive', @@ -42,6 +54,42 @@ final class FrameworkCodeTest extends TestCase 'SeparateProcess', ]; + public function testDeprecationsAreProperlyVersioned(): void + { + $deprecationsWithoutVersion = []; + + foreach ($this->getSourceFiles() as $file) { + $lines = file($file, FILE_IGNORE_NEW_LINES); + + if ($lines === false) { + continue; + } + + foreach ($lines as $number => $line) { + if (! str_contains($line, '@deprecated')) { + continue; + } + + if (preg_match('/((?:\/\*)?\*|\/\/)\s+@deprecated\s+(?P.+?)(?:\s*\*\s*)?$/', $line, $matches) === 1) { + $deprecationText = trim($matches['text']); + + if (preg_match('/^v?\d+\.\d+/', $deprecationText) !== 1) { + $deprecationsWithoutVersion[] = sprintf('%s:%d', $file, ++$number); + } + } + } + } + + $this->assertCount( + 0, + $deprecationsWithoutVersion, + sprintf( + "The following lines contain @deprecated annotations without a version number:\n%s", + implode("\n", array_map(static fn (string $location): string => " * {$location}", $deprecationsWithoutVersion)), + ), + ); + } + /** * @param class-string $class */ @@ -62,7 +110,7 @@ public function testEachTestClassHasCorrectGroupAttributeName(string $class): vo $unrecognizedGroups = array_diff( array_map(static function (ReflectionAttribute $attribute): string { $groupAttribute = $attribute->newInstance(); - assert($groupAttribute instanceof Group); + self::assertInstanceOf(Group::class, $groupAttribute); return $groupAttribute->name(); }, $attributes), @@ -87,6 +135,9 @@ public static function provideEachTestClassHasCorrectGroupAttributeName(): itera } } + /** + * @return list + */ private static function getTestClasses(): array { if (self::$testClasses !== []) { @@ -94,7 +145,6 @@ private static function getTestClasses(): array } helper('filesystem'); - $directory = set_realpath(dirname(__DIR__), true); $iterator = new RecursiveIteratorIterator( @@ -135,4 +185,41 @@ static function (SplFileInfo $file) use ($directory): string { return $testClasses; } + + /** + * @return list + */ + private function getSourceFiles(): array + { + if (self::$sourceFiles !== []) { + return self::$sourceFiles; + } + + helper('filesystem'); + $phpFiles = []; + $basePath = dirname(__DIR__, 3); + + foreach (['system', 'app', 'tests'] as $dir) { + $directory = set_realpath($basePath . DIRECTORY_SEPARATOR . $dir, true); + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( + $directory, + FilesystemIterator::SKIP_DOTS, + ), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isFile() && str_ends_with($file->getPathname(), '.php')) { + $phpFiles[] = $file->getRealPath(); + } + } + } + + self::$sourceFiles = $phpFiles; + + return $phpFiles; + } } diff --git a/tests/system/Autoloader/AutoloaderTest.php b/tests/system/Autoloader/AutoloaderTest.php index be22e4a403f1..9548dec15cda 100644 --- a/tests/system/Autoloader/AutoloaderTest.php +++ b/tests/system/Autoloader/AutoloaderTest.php @@ -16,8 +16,6 @@ use App\Controllers\Home; use Closure; use CodeIgniter\Exceptions\ConfigException; -use CodeIgniter\Exceptions\InvalidArgumentException; -use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ReflectionHelper; use Config\Autoload; @@ -270,53 +268,6 @@ public function testloadClassNonNamespaced(): void $this->assertFalse(($this->classLoader)('Modules')); } - public function testSanitizationContailsSpecialChars(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'The file path contains special characters "${}!#" that are not allowed: "${../path}!#/to/some/file.php_"', - ); - - $test = '${../path}!#/to/some/file.php_'; - - $this->loader->sanitizeFilename($test); - } - - public function testSanitizationFilenameEdges(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'The characters ".-_" are not allowed in filename edges: "/path/to/some/file.php_"', - ); - - $test = '/path/to/some/file.php_'; - - $this->loader->sanitizeFilename($test); - } - - public function testSanitizationRegexError(): void - { - $this->expectException(RuntimeException::class); - - $test = mb_convert_encoding('クラスファイル.php', 'EUC-JP', 'UTF-8'); - - $this->loader->sanitizeFilename($test); - } - - public function testSanitizationAllowUnicodeChars(): void - { - $test = 'Ä/path/to/some/file.php'; - - $this->assertSame($test, $this->loader->sanitizeFilename($test)); - } - - public function testSanitizationAllowsWindowsFilepaths(): void - { - $test = 'C:\path\to\some/file.php'; - - $this->assertSame($test, $this->loader->sanitizeFilename($test)); - } - public function testFindsComposerRoutes(): void { $config = new Autoload(); diff --git a/tests/system/Autoloader/FileLocatorTest.php b/tests/system/Autoloader/FileLocatorTest.php index fc7925724add..07bca58683fb 100644 --- a/tests/system/Autoloader/FileLocatorTest.php +++ b/tests/system/Autoloader/FileLocatorTest.php @@ -316,7 +316,7 @@ public function testGetClassNameFromNonClassFile(): void { $this->assertSame( '', - $this->locator->getClassname(SYSTEMPATH . 'bootstrap.php'), + $this->locator->getClassname(SYSTEMPATH . 'util_bootstrap.php'), ); } diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php new file mode 100644 index 000000000000..bc7b673d19e0 --- /dev/null +++ b/tests/system/CLI/AbstractCommandTest.php @@ -0,0 +1,1169 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Exceptions\ArgumentCountMismatchException; +use CodeIgniter\CLI\Exceptions\CommandNotAvailableException; +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; +use CodeIgniter\CLI\Exceptions\OptionValueMismatchException; +use CodeIgniter\CLI\Exceptions\UnknownOptionException; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Commands\Help; +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use Config\App; +use Config\Services; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; +use ReflectionProperty; +use Tests\Support\Commands\Modern\AppAboutCommand; +use Tests\Support\Commands\Modern\InteractFixtureCommand; +use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; +use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand; +use Tests\Support\Commands\Modern\TestFixtureCommand; +use Tests\Support\Commands\Modern\UnavailableFixtureCommand; +use Throwable; + +/** + * @internal + */ +#[CoversClass(AbstractCommand::class)] +#[Group('Others')] +final class AbstractCommandTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + #[After] + #[Before] + protected function resetAll(): void + { + $this->resetServices(); + + CLI::reset(); + + InteractiveStateProbeCommand::reset(); + UnavailableFixtureCommand::reset(); + } + + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + public function testConstructorSetsNeededProperties(): void + { + $commands = new Commands(); + $command = new Help($commands); + $attribute = (new ReflectionClass($command))->getAttributes(Command::class)[0]->newInstance(); + + $this->assertSame($attribute->name, $command->getName()); + $this->assertSame($attribute->description, $command->getDescription()); + $this->assertSame($attribute->group, $command->getGroup()); + $this->assertSame($commands, $command->getCommandRunner()); + $this->assertSame('help [options] [--] []', $command->getUsages()[0]); + } + + public function testCommandRequiresCommandAttribute(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/^Command class ".*" is missing the CodeIgniter\\\\CLI\\\\Attributes\\\\Command attribute\.$/'); + + new class (new Commands()) extends AbstractCommand { + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } + }; + } + + public function testCommandCanGetDefinitions(): void + { + $command = new Help(new Commands()); + + $this->assertCount(1, $command->getArgumentsDefinition()); + $this->assertCount(3, $command->getOptionsDefinition()); + $this->assertCount(2, $command->getShortcuts()); + $this->assertEmpty($command->getNegations()); + } + + public function testCommandHasDefaultOptions(): void + { + $defaultOptions = ['help', 'no-header', 'no-interaction']; + + $this->assertSame($defaultOptions, array_keys((new Help(new Commands()))->getOptionsDefinition())); + } + + /** + * @param list> $definitions + */ + #[DataProvider('provideCollectionLevelArgumentRegistrationIsRejected')] + public function testCollectionLevelArgumentRegistrationIsRejected(string $message, array $definitions): void + { + $this->expectException(InvalidArgumentDefinitionException::class); + $this->expectExceptionMessage($message); + + $command = new TestFixtureCommand(new Commands()); + + foreach ($definitions as $definition) { + $command->addArgument(new Argument(...$definition)); + } + } + + /** + * @return iterable>}> + */ + public static function provideCollectionLevelArgumentRegistrationIsRejected(): iterable + { + yield 'duplicate name' => [ + 'An argument with the name "command_name" is already defined.', + [ + ['name' => 'command_name', 'default' => 'file'], + ['name' => 'command_name', 'default' => 'file2'], + ], + ]; + + yield 'non-array argument after array argument' => [ + 'Argument "second" cannot be defined after array argument "first".', + [ + ['name' => 'first', 'isArray' => true], + ['name' => 'second', 'default' => 'x'], + ], + ]; + + yield 'required argument after optional argument' => [ + 'Required argument "second" cannot be defined after optional argument "first".', + [ + ['name' => 'first', 'default' => 'value'], + ['name' => 'second', 'required' => true], + ], + ]; + } + + /** + * @param list> $definitions + */ + #[DataProvider('provideCollectionLevelOptionRegistrationIsRejected')] + public function testCollectionLevelOptionRegistrationIsRejected(string $message, array $definitions): void + { + $this->expectException(InvalidOptionDefinitionException::class); + $this->expectExceptionMessage($message); + + $command = new TestFixtureCommand(new Commands()); + + foreach ($definitions as $definition) { + $command->addOption(new Option(...$definition)); + } + } + + /** + * @return iterable>}> + */ + public static function provideCollectionLevelOptionRegistrationIsRejected(): iterable + { + yield 'duplicate name' => [ + 'An option with the name "--test" is already defined.', + [ + ['name' => 'test'], + ['name' => 'test'], + ], + ]; + + yield 'shortcut name already in use' => [ + 'Shortcut "-t" cannot be used for option "--test2"; it is already assigned to option "--test1".', + [ + ['name' => 'test1', 'shortcut' => 't'], + ['name' => 'test2', 'shortcut' => 't'], + ], + ]; + + yield 'negatable option already defined as option' => [ + 'Negatable option "--test" cannot be defined because its negation "--no-test" already exists as an option.', + [ + ['name' => 'no-test'], + ['name' => 'test', 'negatable' => true, 'default' => false], + ], + ]; + + yield 'option name clashes with existing negation' => [ + 'Option "--no-test" clashes with the negation of negatable option "--test".', + [ + ['name' => 'test', 'negatable' => true, 'default' => false], + ['name' => 'no-test'], + ], + ]; + } + + public function testRenderThrowable(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(EXIT_ERROR, $command->bomb()); + $this->assertStringContainsString('[CodeIgniter\CLI\Exceptions\CLIException]', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Invalid "background" color: "Background".', $this->getStreamFilterBuffer()); + } + + public function testRenderThrowableSwapsNonCliRequestAndRestores(): void + { + // Seed the shared request with a non-CLI instance so renderThrowable() + // exercises the swap-and-restore branch. + Services::createRequest(config(App::class)); + $incoming = Services::get('request'); + + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(EXIT_ERROR, $command->bomb()); + $this->assertStringContainsString('Invalid "background" color: "Background".', $this->getStreamFilterBuffer()); + + $this->assertSame($incoming, Services::get('request')); + } + + public function testCheckingOfArgumentsAndOptions(): void + { + $command = new Help(new Commands()); + + $this->assertTrue($command->hasArgument('command_name')); + $this->assertFalse($command->hasArgument('lorem')); + $this->assertTrue($command->hasOption('help')); + $this->assertTrue($command->hasOption('no-header')); + $this->assertTrue($command->hasOption('no-interaction')); + $this->assertFalse($command->hasOption('lorem')); + $this->assertTrue($command->hasShortcut('h')); + $this->assertTrue($command->hasShortcut('N')); + $this->assertFalse($command->hasShortcut('x')); + $this->assertFalse($command->hasNegation('no-help')); + } + + public function testCommandCanCallAnotherCommand(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(0, $command->helpMe()); + $this->assertStringContainsString('help [options] [--] []', $this->getStreamFilterBuffer()); + } + + public function testCallSilentlySuppressesSubCommandOutputAndReturnsExitCode(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(EXIT_SUCCESS, $command->helpMeSilently()); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testCallSilentlyRestoresPriorIo(): void + { + $custom = new InputOutput(); + CLI::setInputOutput($custom); + + $command = new AppAboutCommand(new Commands()); + $command->helpMeSilently(); + + $this->assertSame($custom, CLI::getInputOutput()); + } + + public function testCallSilentlyResetsToFreshInputOutputWhenPriorWasNull(): void + { + $property = new ReflectionProperty(CLI::class, 'io'); + $property->setValue(null, null); + + $command = new AppAboutCommand(new Commands()); + $command->helpMeSilently(); + + $current = CLI::getInputOutput(); + $this->assertInstanceOf(InputOutput::class, $current); + $this->assertNotInstanceOf(NullInputOutput::class, $current); + } + + public function testCallSilentlyPropagatesSubCommandNonZeroExitCode(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(EXIT_ERROR, $command->callUnknownSilently()); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testCallSilentlyRestoresPriorLastWriteState(): void + { + CLI::setLastWrite(null); + + $command = new AppAboutCommand(new Commands()); + $command->helpMeSilently(); + + $this->assertNull( + CLI::getLastWrite(), + 'callSilently() must not leak the silenced sub-command\'s $lastWrite mutation back to the parent.', + ); + } + + public function testCallSilentlyForwardsNoInteractionOverrideFalseToChild(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + $command->setInteractive(false); + $command->useCallSilently = true; + $command->childNoInteractionOverride = false; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFalse($command->isInteractive()); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + $this->assertTrue(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testRunCommand(): void + { + command('app:about a'); + + $this->assertSame( + sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION), + $this->getUndecoratedBuffer(), + ); + } + + /** + * @param list $arguments + */ + #[DataProvider('provideBindingOfArguments')] + public function testBindingOfArguments(array $arguments, string $key, mixed $value): void + { + $command = new AppAboutCommand(new Commands()); + $command->run($arguments, []); + + $this->assertSame($value, $command->callGetValidatedArgument($key)); + } + + /** + * @return iterable, string, mixed}> + */ + public static function provideBindingOfArguments(): iterable + { + yield 'Required argument provided [app:about a]' => [ + ['a'], + 'required', + 'a', + ]; + + yield 'Optional argument omitted [app:about a]' => [ + ['a'], + 'optional', + 'val', // default value + ]; + + yield 'Optional argument provided [app:about a opt]' => [ + ['a', 'opt'], + 'optional', + 'opt', + ]; + + yield 'Array argument omitted [app:about a]' => [ + ['a'], + 'array', + ['a', 'b'], // default values + ]; + + yield 'Multiple array arguments provided [app:about a b x y]' => [ + ['a', 'b', 'x', 'y'], + 'array', + ['x', 'y'], + ]; + + yield 'One array argument provided [app:about a b z]' => [ + ['a', 'b', 'z'], + 'array', + ['z'], + ]; + } + + /** + * @param array $options + */ + #[DataProvider('provideBindingOfOptions')] + public function testBindingOfOptions(array $options, string $key, mixed $value): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], $options); + + $this->assertSame($value, $command->callGetValidatedOption($key)); + } + + /** + * @return iterable, string, mixed}> + */ + public static function provideBindingOfOptions(): iterable + { + yield 'Option requiring value [app:about a --foo=bar]' => [ + ['foo' => 'bar'], + 'foo', + 'bar', + ]; + + yield 'Option shortcut requiring value [app:about a -f bar]' => [ + ['f' => 'bar'], + 'foo', + 'bar', + ]; + + yield 'Option optionally accepting value [app:about a --bar]' => [ + ['bar' => null], + 'bar', + null, + ]; + + yield 'Option shortcut optionally accepting value [app:about a -a 3]' => [ + ['a' => '3'], + 'bar', + '3', + ]; + + yield 'Option allowing multiple values [app:about a --baz=val1]' => [ + ['baz' => ['val1']], + 'baz', + ['val1'], + ]; + + yield 'Option allowing multiple values [app:about a --baz=val1] (as string)' => [ + ['baz' => 'val1'], + 'baz', + ['val1'], + ]; + + yield 'Option allowing multiple values [app:about a --baz=val1 --baz=val2]' => [ + ['baz' => ['val1', 'val2']], + 'baz', + ['val1', 'val2'], + ]; + + yield 'Option shortcut allowing multiple values [app:about a -b 1 -b 2]' => [ + ['b' => ['1', '2']], + 'baz', + ['1', '2'], + ]; + + yield 'Option and shortcut allowing multiple values [app:about a -b 1 --baz 2]' => [ + ['b' => '1', 'baz' => '2'], + 'baz', + ['2', '1'], // long names of array options are recognised first + ]; + + yield 'Array option with shortcut passed multiple times after long name [app:about a --baz 1 -b 2 -b 3]' => [ + ['baz' => '1', 'b' => ['2', '3']], + 'baz', + ['1', '2', '3'], // leftover shortcut values are flattened, not nested + ]; + + yield 'Negatable option provided [app:about a --quux]' => [ + ['quux' => null], + 'quux', + true, + ]; + + yield 'Negated option provided [app:about a --no-quux]' => [ + ['no-quux' => null], + 'quux', + false, + ]; + } + + /** + * @param list $arguments + */ + #[DataProvider('provideValidationNoArgumentsExpected')] + public function testValidationNoArgumentsExpected(string $message, array $arguments): void + { + $command = new TestFixtureCommand(new Commands()); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage($message); + + $command->run($arguments, []); + } + + /** + * @return iterable}> + */ + public static function provideValidationNoArgumentsExpected(): iterable + { + yield 'one extra argument [test:fixture a]' => [ + 'No arguments expected for "test:fixture" command. Received: "a".', + ['a'], + ]; + + yield 'two extra arguments [test:fixture a b]' => [ + 'No arguments expected for "test:fixture" command. Received: "a", "b".', + ['a', 'b'], + ]; + } + + /** + * @param list $arguments + */ + #[DataProvider('provideValidationTooManyArguments')] + public function testValidationTooManyArguments(string $message, array $arguments): void + { + $command = (new TestFixtureCommand(new Commands())) + ->addArgument(new Argument(name: 'first', default: 'a')) + ->addArgument(new Argument(name: 'second', default: 'b')); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage($message); + + $command->run($arguments, []); + } + + /** + * @return iterable}> + */ + public static function provideValidationTooManyArguments(): iterable + { + yield 'one extra argument [test:fixture a b c]' => [ + 'One unexpected argument was provided to "test:fixture" command: "c".', + ['a', 'b', 'c'], + ]; + + yield 'two extra arguments [test:fixture a b c d]' => [ + 'Multiple unexpected arguments were provided to "test:fixture" command: "c", "d".', + ['a', 'b', 'c', 'd'], + ]; + } + + public function testValidationWithMissingRequiredArgument(): void + { + $command = new TestFixtureCommand(new Commands()); + $command->addArgument(new Argument(name: 'required_arg', required: true)); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage('Command "test:fixture" is missing the following required argument: required_arg.'); + + $command->run([], []); + } + + public function testValidationWithMissingMultipleRequiredArguments(): void + { + $command = new TestFixtureCommand(new Commands()); + $command->addArgument(new Argument(name: 'first_required', required: true)); + $command->addArgument(new Argument(name: 'second_required', required: true)); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage('Command "test:fixture" is missing the following required arguments: first_required, second_required.'); + + $command->run([], []); + } + + /** + * @param class-string $exception + * @param array $options + */ + #[DataProvider('provideValidationOfOptions')] + public function testValidationOfOptions(string $exception, string $message, array $options): void + { + $command = new AppAboutCommand(new Commands()); + + $this->expectException($exception); + $this->expectExceptionMessage($message); + + $command->run(['a'], $options); + } + + /** + * @return iterable, string, array}> + */ + public static function provideValidationOfOptions(): iterable + { + yield 'flag option passed multiple times [app:about a --help --help]' => [ + LogicException::class, + 'Option "--help" is passed multiple times.', + ['help' => [null, null]], + ]; + + yield 'flag option shortcut passed multiple times [app:about a -h -h]' => [ + LogicException::class, + 'Option "--help" is passed multiple times.', + ['h' => [null, null]], + ]; + + yield 'flag option and its shortcut passed [app:about a --help -h]' => [ + LogicException::class, + 'Option "--help" is passed multiple times.', + ['help' => null, 'h' => null], + ]; + + yield 'flag option passed with value [app:about a --help=value]' => [ + OptionValueMismatchException::class, + 'Option "--help" does not accept a value.', + ['help' => 'value'], + ]; + + yield 'option requiring value passed without value [app:about a --foo]' => [ + OptionValueMismatchException::class, + 'Option "--foo" requires a value to be provided.', + ['foo' => null], + ]; + + yield 'array option requiring value passed without value [app:about a --baz]' => [ + OptionValueMismatchException::class, + 'Option "--baz" requires a value to be provided.', + ['baz' => null], + ]; + + yield 'array option requiring value passed without value multiple times [app:about a --baz --baz]' => [ + OptionValueMismatchException::class, + 'Option "--baz" requires a value to be provided.', + ['baz' => [null, null]], + ]; + + yield 'array option requiring value mixing value and no-value [app:about a --baz=v1 --baz]' => [ + OptionValueMismatchException::class, + 'Option "--baz" requires a value to be provided.', + ['baz' => ['v1', null]], + ]; + + yield 'option not accepting value passed with value [app:about a --no-header=value]' => [ + OptionValueMismatchException::class, + 'Option "--no-header" does not accept a value.', + ['no-header' => 'value'], + ]; + + yield 'negatable option passed with value [app:about a --quux=value]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" does not accept a value.', + ['quux' => 'value'], + ]; + + yield 'negation of negatable option passed with value [app:about a --no-quux=value]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" does not accept a value.', + ['no-quux' => 'value'], + ]; + + yield 'non-array option accepting value passed multiple times [app:about a --foo=a --foo=b]' => [ + OptionValueMismatchException::class, + 'Option "--foo" does not accept an array value.', + ['foo' => ['a', 'b']], + ]; + + yield 'non-array option accepting value passed multiple times via shortcut [app:about a -f c -f d]' => [ + OptionValueMismatchException::class, + 'Option "--foo" does not accept an array value.', + ['f' => ['c', 'd']], + ]; + + yield 'negatable option passed multiple times [app:about a --quux --quux]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" is passed multiple times.', + ['quux' => [null, null]], + ]; + + yield 'negatable option passed multiple times some with value [app:about a --quux --quux=b]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" is passed multiple times.', + ['quux' => [null, 'b']], + ]; + + yield 'negatable option passed multiple times all with values [app:about a --quux=b --quux=c]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" is passed multiple times.', + ['quux' => ['b', 'c']], + ]; + + yield 'negation of negatable option passed multiple times [app:about a --no-quux --no-quux]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" is passed multiple times.', + ['no-quux' => [null, null]], + ]; + + yield 'negation of negatable option passed multiple times some with value [app:about a --no-quux --no-quux=d]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" is passed multiple times.', + ['no-quux' => [null, 'd']], + ]; + + yield 'negation of negatable option passed multiple times all with values [app:about a --no-quux=e --no-quux=f]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" is passed multiple times.', + ['no-quux' => ['e', 'f']], + ]; + + yield 'negatable option passed with its negation [app:about a --quux --no-quux]' => [ + LogicException::class, + 'Option "--quux" and its negation "--no-quux" cannot be used together.', + ['quux' => null, 'no-quux' => null], + ]; + + yield 'negatable option passed with its negation carrying a value [app:about a --quux --no-quux=text]' => [ + LogicException::class, + 'Option "--quux" and its negation "--no-quux" cannot be used together.', + ['quux' => null, 'no-quux' => 'text'], + ]; + + yield 'negatable option passed with its negation multiple times [app:about a --quux --no-quux --no-quux]' => [ + LogicException::class, + 'Option "--quux" and its negation "--no-quux" cannot be used together.', + ['quux' => null, 'no-quux' => [null, null]], + ]; + + yield 'negatable option passed multiple times with its negation [app:about a --quux --quux --no-quux]' => [ + LogicException::class, + 'Option "--quux" and its negation "--no-quux" cannot be used together.', + ['quux' => [null, null], 'no-quux' => null], + ]; + + yield 'unknown option passed [app:about a --unknown]' => [ + UnknownOptionException::class, + 'The following option is unknown in the "app:about" command: --unknown.', + ['unknown' => 'value'], + ]; + + yield 'multiple unknown options passed [app:about a --unknown1 --unknown2]' => [ + UnknownOptionException::class, + 'The following options are unknown in the "app:about" command: --unknown1, --unknown2.', + ['unknown1' => 'value', 'unknown2' => 'value'], + ]; + } + + public function testInteractMutationsCarryThroughToExecute(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->run([], []); + + $this->assertTrue($command->isInteractive()); + $this->assertSame(['name' => 'from-interact'], $command->executedArguments); + $this->assertTrue($command->executedOptions['force']); + } + + public function testInteractIsSkippedWhenNoInteractionFlagIsPassed(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->run([], ['no-interaction' => null]); + + $this->assertFalse($command->isInteractive()); + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + $this->assertFalse($command->executedOptions['force']); + } + + public function testInteractIsSkippedWhenShortcutFlagIsPassed(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->run([], ['N' => null]); + + $this->assertFalse($command->isInteractive()); + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + $this->assertFalse($command->executedOptions['force']); + } + + public function testInteractIsSkippedWhenSetInteractiveFalseIsCalled(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->setInteractive(false); + $this->assertFalse($command->isInteractive()); + + $command->run([], []); + + $this->assertFalse($command->isInteractive()); + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + $this->assertFalse($command->executedOptions['force']); + } + + public function testSetInteractiveTrueOverridesNoInteractionFlag(): void + { + // Explicit caller intent wins over the CLI flag. + $command = new InteractFixtureCommand(new Commands()); + $command->setInteractive(true); + $command->run([], ['no-interaction' => null]); + + $this->assertTrue($command->isInteractive()); + $this->assertSame(['name' => 'from-interact'], $command->executedArguments); + $this->assertTrue($command->executedOptions['force']); + } + + public function testNoInteractionFlagDoesNotLeakAcrossRuns(): void + { + $command = new InteractFixtureCommand(new Commands()); + + $command->run([], ['no-interaction' => null]); + $this->assertFalse($command->isInteractive()); + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + $this->assertFalse($command->executedOptions['force']); + + $command->run([], []); + $this->assertTrue($command->isInteractive()); + $this->assertSame(['name' => 'from-interact'], $command->executedArguments); + $this->assertTrue($command->executedOptions['force']); + } + + public function testSetInteractiveCallPersistsAcrossRuns(): void + { + $command = new InteractFixtureCommand(new Commands()); + $command->setInteractive(false); + $this->assertFalse($command->isInteractive()); + + $command->run([], []); + $this->assertFalse($command->isInteractive()); + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + + $command->run([], []); + $this->assertFalse($command->isInteractive()); + $this->assertSame(['name' => 'anonymous'], $command->executedArguments); + } + + public function testIsInteractiveReflectsExplicitState(): void + { + $command = new InteractFixtureCommand(new Commands()); + + // Default: in the testing env, `CLI::streamSupports('stream_isatty', STDIN)` + // resolves to `function_exists('stream_isatty')`, which is true on PHP 8.1+. + $this->assertTrue($command->isInteractive()); + + $command->setInteractive(false); + $this->assertFalse($command->isInteractive()); + + $command->setInteractive(true); + $this->assertTrue($command->isInteractive()); + } + + public function testNoInteractionCascadesToSubCommandsViaCall(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $exitCode = $command->run([], ['no-interaction' => null]); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFalse($command->isInteractive()); + $this->assertFalse(InteractiveStateProbeCommand::$interactCalled); + $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testSubCommandStaysInteractiveWhenParentIsInteractive(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue($command->isInteractive()); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + $this->assertTrue(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testCallAllowsSubCommandInteractiveEvenWhenParentIsNonInteractive(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + $command->setInteractive(false); + $command->childNoInteractionOverride = false; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFalse($command->isInteractive()); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + $this->assertTrue(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testCallForcesSubCommandNonInteractiveEvenWhenParentIsInteractive(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $command->childNoInteractionOverride = true; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue($command->isInteractive()); + $this->assertFalse(InteractiveStateProbeCommand::$interactCalled); + $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); + } + + /** + * Caller passes --no-interaction in the sub-command's options, but also + * sets noInteractionOverride to false: the explicit parameter wins and + * the inherited flag is stripped under both its long name and its shortcut. + */ + public function testCallStripsInheritedNoInteractionWhenCallerAllowsInteraction(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $command->childNoInteractionOverride = false; + + $command->childOptions = ['no-interaction' => null, 'N' => null]; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue($command->isInteractive()); + $this->assertTrue(InteractiveStateProbeCommand::$interactCalled); + $this->assertTrue(InteractiveStateProbeCommand::$observedInteractive); + } + + /** + * When $noInteractionOverride is true and the caller already supplied the flag, + * the resolver must not touch the caller's entry. The child still sees a + * non-interactive state. + */ + public function testCallPreservesCallerFlagWhenForcingNonInteractive(): void + { + $command = new ParentCallsInteractFixtureCommand(new Commands()); + + $command->childNoInteractionOverride = true; + + $command->childOptions = ['no-interaction' => null]; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue($command->isInteractive()); + $this->assertFalse(InteractiveStateProbeCommand::$interactCalled); + $this->assertFalse(InteractiveStateProbeCommand::$observedInteractive); + } + + public function testRunThrowsWhenCommandIsUnavailable(): void + { + $command = new UnavailableFixtureCommand(new Commands()); + + UnavailableFixtureCommand::$available = false; + + $this->expectException(CommandNotAvailableException::class); + $this->expectExceptionMessage('Command "test:unavailable" is not available in the current environment.'); + + $command->run([], []); + } + + public function testRunExecutesWhenCommandIsAvailable(): void + { + $command = new UnavailableFixtureCommand(new Commands()); + + UnavailableFixtureCommand::$available = true; + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertTrue(UnavailableFixtureCommand::$initializeCalled); + $this->assertTrue(UnavailableFixtureCommand::$interactCalled); + $this->assertTrue(UnavailableFixtureCommand::$executeCalled); + } + + public function testRunChecksAvailabilityBeforeInitializeInteractAndExecute(): void + { + $command = new UnavailableFixtureCommand(new Commands()); + + UnavailableFixtureCommand::$available = false; + + try { + $command->run([], []); + $this->fail('Expected CommandNotAvailableException was not thrown.'); + } catch (CommandNotAvailableException) { + $this->assertFalse(UnavailableFixtureCommand::$initializeCalled); + $this->assertFalse(UnavailableFixtureCommand::$interactCalled); + $this->assertFalse(UnavailableFixtureCommand::$executeCalled); + } + } + + /** + * @param array|string|null> $options + */ + #[DataProvider('provideHasUnboundOptionResolvesAlias')] + public function testHasUnboundOptionResolvesAlias(string $name, array $options, bool $expected): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame($expected, $command->callHasUnboundOption($name, $options)); + } + + /** + * @return iterable|string|null>, bool}> + */ + public static function provideHasUnboundOptionResolvesAlias(): iterable + { + yield 'long name' => ['foo', ['foo' => 'bar'], true]; + + yield 'shortcut' => ['foo', ['f' => 'bar'], true]; + + yield 'negation' => ['quux', ['no-quux' => null], true]; + + yield 'not provided' => ['foo', [], false]; + } + + public function testHasUnboundOptionThrowsForUndeclaredOption(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Option "undeclared" is not defined on this command.'); + + $command->callHasUnboundOption('undeclared', []); + } + + /** + * @param array|string|null> $options + * @param list|string|null $expected + */ + #[DataProvider('provideGetUnboundOptionResolvesAlias')] + public function testGetUnboundOptionResolvesAlias(string $name, array $options, array|string|null $expected): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame($expected, $command->callGetUnboundOption($name, $options)); + } + + /** + * @return iterable|string|null>, list|string|null}> + */ + public static function provideGetUnboundOptionResolvesAlias(): iterable + { + yield 'long name' => ['foo', ['foo' => 'bar'], 'bar']; + + yield 'shortcut' => ['foo', ['f' => 'bar'], 'bar']; + + yield 'negation' => ['quux', ['no-quux' => null], null]; + + yield 'not provided' => ['foo', [], null]; + } + + public function testGetUnboundOptionThrowsForUndeclaredOption(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Option "undeclared" is not defined on this command.'); + + $command->callGetUnboundOption('undeclared', []); + } + + public function testUnboundOptionHelpersFallBackToInstanceStateAfterRun(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['f' => 'shortcut-value']); + + // Options array passed to run() is the raw (unbound) input. After run() + // completes, $this->unboundOptions holds that snapshot. Calling the + // helpers with $options = null should read from that state. + $this->assertTrue($command->callHasUnboundOption('foo')); + $this->assertSame('shortcut-value', $command->callGetUnboundOption('foo')); + } + + public function testGetUnboundArgumentsReturnsRawArgumentList(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a', 'b', 'extra'], []); + + $this->assertSame(['a', 'b', 'extra'], $command->callGetUnboundArguments()); + } + + public function testGetUnboundArgumentByIndex(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a', 'b'], []); + + $this->assertSame('a', $command->callGetUnboundArgument(0)); + $this->assertSame('b', $command->callGetUnboundArgument(1)); + } + + public function testGetUnboundArgumentThrowsForMissingIndex(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unbound argument at index "5" does not exist.'); + + $command->callGetUnboundArgument(5); + } + + public function testGetUnboundOptionsReturnsRawOptionMap(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['f' => 'shortcut', 'bar' => 'longname']); + + $this->assertSame( + ['f' => 'shortcut', 'bar' => 'longname'], + $command->callGetUnboundOptions(), + ); + } + + public function testGetValidatedArgumentsReflectsDefaultsAfterBinding(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->assertSame( + ['required' => 'a', 'optional' => 'val', 'array' => ['a', 'b']], + $command->callGetValidatedArguments(), + ); + } + + public function testGetValidatedArgumentByName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['hello', 'world'], []); + + $this->assertSame('hello', $command->callGetValidatedArgument('required')); + $this->assertSame('world', $command->callGetValidatedArgument('optional')); + } + + public function testGetValidatedArgumentThrowsForUnknownName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Validated argument with name "missing" does not exist.'); + + $command->callGetValidatedArgument('missing'); + } + + public function testGetValidatedOptionsReflectsDefaultsAfterBinding(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['foo' => 'provided']); + + $this->assertSame( + [ + 'foo' => 'provided', + 'bar' => null, + 'baz' => ['a'], + 'quux' => false, + 'help' => false, + 'no-header' => false, + 'no-interaction' => false, + ], + $command->callGetValidatedOptions(), + ); + } + + public function testGetValidatedOptionByName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['foo' => 'provided']); + + $this->assertSame('provided', $command->callGetValidatedOption('foo')); + $this->assertFalse($command->callGetValidatedOption('help')); + } + + public function testGetValidatedOptionThrowsForUnknownName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Validated option with name "missing" does not exist.'); + + $command->callGetValidatedOption('missing'); + } +} diff --git a/tests/system/CLI/Attributes/CommandTest.php b/tests/system/CLI/Attributes/CommandTest.php new file mode 100644 index 000000000000..ef8523d09122 --- /dev/null +++ b/tests/system/CLI/Attributes/CommandTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Attributes; + +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Command::class)] +#[Group('Others')] +final class CommandTest extends CIUnitTestCase +{ + public function testAttributeExposesProperties(): void + { + $command = new Command(name: 'app:about', description: 'Displays basic info.', group: 'App'); + + $this->assertSame('app:about', $command->name); + $this->assertSame('Displays basic info.', $command->description); + $this->assertSame('App', $command->group); + } + + public function testAttributeAllowsOmittedDescriptionAndGroup(): void + { + $command = new Command(name: 'app:about'); + + $this->assertSame('', $command->description); + $this->assertSame('', $command->group); + $this->assertSame([], $command->aliases); + } + + public function testAttributeExposesAliases(): void + { + $command = new Command(name: 'app:about', aliases: ['app:ab', 'ab']); + + $this->assertSame(['app:ab', 'ab'], $command->aliases); + } + + /** + * @param array $parameters + */ + #[DataProvider('provideInvalidDefinitionsAreRejected')] + public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage($message); + + new Command(...$parameters); + } + + /** + * @return iterable}> + */ + public static function provideInvalidDefinitionsAreRejected(): iterable + { + yield 'empty name' => [ + 'Command name cannot be empty.', + ['name' => ''], + ]; + + yield 'name with whitespace' => [ + 'Command name "invalid name" is not valid.', + ['name' => 'invalid name'], + ]; + + yield 'name starting with colon' => [ + 'Command name ":invalid" is not valid.', + ['name' => ':invalid'], + ]; + + yield 'name ending with colon' => [ + 'Command name "invalid:" is not valid.', + ['name' => 'invalid:'], + ]; + + yield 'name with consecutive colons' => [ + 'Command name "app::about" is not valid.', + ['name' => 'app::about'], + ]; + + yield 'empty alias' => [ + 'Command alias "" is not valid.', + ['name' => 'app:about', 'aliases' => ['']], + ]; + + yield 'alias with whitespace' => [ + 'Command alias "bad alias" is not valid.', + ['name' => 'app:about', 'aliases' => ['bad alias']], + ]; + + yield 'alias same as name' => [ + 'Command alias "app:about" cannot be the same as the command name.', + ['name' => 'app:about', 'aliases' => ['app:about']], + ]; + + yield 'duplicate alias' => [ + 'Command alias "ab" is defined more than once.', + ['name' => 'app:about', 'aliases' => ['ab', 'ab']], + ]; + } +} diff --git a/tests/system/CLI/BaseCommandTest.php b/tests/system/CLI/BaseCommandTest.php index 143851dee9ce..7923f7667ed2 100644 --- a/tests/system/CLI/BaseCommandTest.php +++ b/tests/system/CLI/BaseCommandTest.php @@ -22,7 +22,7 @@ use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; -use Tests\Support\Commands\AppInfo; +use Tests\Support\Commands\Legacy\AppInfo; /** * @internal diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 9391eae05d27..f5c9f434acdf 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -391,6 +391,48 @@ public function testWriteBackground(): void $this->assertSame($expected, $this->getStreamFilterBuffer()); } + public function testGetLastWriteReturnsNullAfterReset(): void + { + CLI::resetLastWrite(); + + $this->assertNull(CLI::getLastWrite()); + } + + public function testGetLastWriteReflectsPriorWrite(): void + { + CLI::resetLastWrite(); + CLI::write('hello'); + + $this->assertSame('write', CLI::getLastWrite()); + } + + public function testGetLastWriteReflectsPriorPrint(): void + { + CLI::resetLastWrite(); + CLI::write('hello'); + CLI::print('world'); + + $this->assertNull(CLI::getLastWrite()); + } + + public function testSetLastWriteRoundTrips(): void + { + CLI::setLastWrite('write'); + $this->assertSame('write', CLI::getLastWrite()); + + CLI::setLastWrite(null); + $this->assertNull(CLI::getLastWrite()); + } + + public function testSetLastWriteSuppressesLeadingNewlineOnNextWrite(): void + { + CLI::setLastWrite('write'); + + CLI::write('hello'); + + $this->assertSame('hello' . PHP_EOL, $this->getStreamFilterBuffer()); + } + public function testError(): void { CLI::error('test'); @@ -582,6 +624,89 @@ public function testParseCommandMultipleOptions(): void $this->assertSame(['b', 'c', 'd'], CLI::getSegments()); } + public function testParseCommandMultipleAndArrayOptions(): void + { + service('superglobals')->setServer('argv', [ + 'ignored', + 'b', + 'c', + '--p1', + 'value', + 'd', + '--p2', + '--p3', + 'value 3', + '--p3', + 'value 3.1', + ]); + CLI::init(); + + $this->assertSame(['p1' => 'value', 'p2' => null, 'p3' => ['value 3', 'value 3.1']], CLI::getOptions()); + $this->assertSame('value', CLI::getOption('p1')); + $this->assertTrue(CLI::getOption('p2')); + $this->assertSame('value 3.1', CLI::getOption('p3')); + $this->assertSame(['value 3', 'value 3.1'], CLI::getRawOption('p3')); + $this->assertSame('-p1 value -p2 -p3 "value 3" -p3 "value 3.1" ', CLI::getOptionString()); + $this->assertSame('-p1 value -p2 -p3 "value 3" -p3 "value 3.1"', CLI::getOptionString(false, true)); + $this->assertSame('--p1 value --p2 --p3 "value 3" --p3 "value 3.1" ', CLI::getOptionString(true)); + $this->assertSame('--p1 value --p2 --p3 "value 3" --p3 "value 3.1"', CLI::getOptionString(true, true)); + $this->assertSame(['b', 'c', 'd'], CLI::getSegments()); + } + + public function testParseCommandRepeatedFlagOption(): void + { + service('superglobals')->setServer('argv', [ + 'ignored', + 'b', + '--p1', + '--p2', + '--p2', + ]); + CLI::init(); + + $this->assertSame(['p1' => null, 'p2' => [null, null]], CLI::getOptions()); + $this->assertTrue(CLI::getOption('p1')); + $this->assertTrue(CLI::getRawOption('p1')); + $this->assertTrue(CLI::getOption('p2')); + $this->assertSame([null, null], CLI::getRawOption('p2')); + $this->assertSame('-p1 -p2 -p2 ', CLI::getOptionString()); + $this->assertSame('--p1 --p2 --p2', CLI::getOptionString(true, true)); + $this->assertSame(['b'], CLI::getSegments()); + } + + /** + * @param list $options + */ + #[DataProvider('provideGetOptionString')] + public function testGetOptionString(array $options, string $optionString): void + { + service('superglobals')->setServer('argv', ['spark', 'b', 'c', ...$options]); + CLI::init(); + + $this->assertSame($optionString, CLI::getOptionString(true, true)); + } + + /** + * @return iterable, 1: string}> + */ + public static function provideGetOptionString(): iterable + { + yield [ + ['--parm', 'pvalue'], + '--parm pvalue', + ]; + + yield [ + ['--parm', 'p value'], + '--parm "p value"', + ]; + + yield [ + ['--key', 'val1', '--key', 'val2', '--opt', '--bar'], + '--key val1 --key val2 --opt --bar', + ]; + } + public function testWindow(): void { $height = new ReflectionProperty(CLI::class, 'height'); diff --git a/tests/system/CLI/CommandLineParserTest.php b/tests/system/CLI/CommandLineParserTest.php new file mode 100644 index 000000000000..9ff28dfed5df --- /dev/null +++ b/tests/system/CLI/CommandLineParserTest.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class CommandLineParserTest extends CIUnitTestCase +{ + /** + * @param list $tokens + * @param list $arguments + * @param array|string|null> $options + */ + #[DataProvider('provideParseCommand')] + public function testParseCommand(array $tokens, array $arguments, array $options): void + { + $parser = new CommandLineParser(['spark', ...$tokens]); + + $this->assertSame($arguments, $parser->getArguments()); + $this->assertSame($options, $parser->getOptions()); + } + + /** + * @return iterable, 1: list, 2: array|string|null>}> + */ + public static function provideParseCommand(): iterable + { + yield 'no arguments or options' => [ + [], + [], + [], + ]; + + yield 'arguments only' => [ + ['foo', 'bar'], + ['foo', 'bar'], + [], + ]; + + yield 'options only' => [ + ['--foo', '1', '--bar', '2'], + [], + ['foo' => '1', 'bar' => '2'], + ]; + + yield 'arguments and options' => [ + ['foo', '--bar', '2', 'baz', '--qux', '3'], + ['foo', 'baz'], + ['bar' => '2', 'qux' => '3'], + ]; + + yield 'options with null value' => [ + ['--foo', '--bar', '2'], + [], + ['foo' => null, 'bar' => '2'], + ]; + + yield 'options before double hyphen' => [ + ['b', 'c', '--key', 'value', '--', 'd'], + ['b', 'c', 'd'], + ['key' => 'value'], + ]; + + yield 'options after double hyphen' => [ + ['b', 'c', '--', '--key', 'value', 'd'], + ['b', 'c', '--key', 'value', 'd'], + [], + ]; + + yield 'options before and after double hyphen' => [ + ['b', 'c', '--key', 'value', '--', '--p2', 'value 2', 'd'], + ['b', 'c', '--p2', 'value 2', 'd'], + ['key' => 'value'], + ]; + + yield 'double hyphen only' => [ + ['b', 'c', '--', 'd'], + ['b', 'c', 'd'], + [], + ]; + + yield 'options before segments with double hyphen' => [ + ['--key', 'value', '--foo', '--', 'b', 'c', 'd'], + ['b', 'c', 'd'], + ['key' => 'value', 'foo' => null], + ]; + + yield 'options before segments with double hyphen and no options' => [ + ['--', 'b', 'c', 'd'], + ['b', 'c', 'd'], + [], + ]; + + yield 'options with equals sign' => [ + ['--key=value', '--foo='], + [], + ['key' => 'value', 'foo' => ''], + ]; + + yield 'options with equals sign and double hyphen' => [ + ['--key=value', '--foo=', 'bar', '--', 'b', 'c', 'd'], + ['bar', 'b', 'c', 'd'], + ['key' => 'value', 'foo' => ''], + ]; + + yield 'mixed options with and without equals sign' => [ + ['--key=value', '--foo', 'bar', '--', 'b', 'c', 'd'], + ['b', 'c', 'd'], + ['key' => 'value', 'foo' => 'bar'], + ]; + + yield 'multiple options with same name' => [ + ['--key=value1', '--key=value2', '--key', 'value3'], + [], + ['key' => ['value1', 'value2', 'value3']], + ]; + + yield 'array options dispersed among arguments' => [ + ['--key=value1', 'arg1', '--key', 'value2', 'arg2', '--key', 'value3'], + ['arg1', 'arg2'], + ['key' => ['value1', 'value2', 'value3']], + ]; + } +} diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 34bb0601da65..3332259f1934 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -13,24 +13,43 @@ namespace CodeIgniter\CLI; +use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Autoloader\FileLocatorInterface; +use CodeIgniter\CLI\Exceptions\CommandNotFoundException; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\Log\Logger; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\ReflectionHelper; use CodeIgniter\Test\StreamFilterTrait; use Config\Services; +use ErrorException; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; use RuntimeException; -use Tests\Support\Commands\AppInfo; +use Tests\Support\Commands\Legacy\AppInfo; +use Tests\Support\Commands\Modern\AliasedCommand; +use Tests\Support\Commands\Modern\AppAboutCommand; +use Tests\Support\Duplicates\DuplicateLegacy; +use Tests\Support\Duplicates\DuplicateModern; +use Tests\Support\InvalidCommands\AliasClashCommand; +use Tests\Support\InvalidCommands\AliasSecondClashCommand; +use Tests\Support\InvalidCommands\AliasTargetCommand; +use Tests\Support\InvalidCommands\EmptyCommandName; +use Tests\Support\InvalidCommands\NoAttributeCommand; /** * @internal */ #[CoversClass(Commands::class)] +#[CoversClass(CommandNotFoundException::class)] #[Group('Others')] final class CommandsTest extends CIUnitTestCase { + use ReflectionHelper; use StreamFilterTrait; #[After] @@ -42,36 +61,51 @@ protected function resetAll(): void CLI::reset(); } - private function copyAppListCommands(): void + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + private function copyCommand(string $path): void { if (! is_dir(APPPATH . 'Commands')) { mkdir(APPPATH . 'Commands'); } - copy(SUPPORTPATH . '_command/ListCommands.php', APPPATH . 'Commands/ListCommands.php'); + copy($path, APPPATH . 'Commands/' . basename($path)); + clearstatcache(true); } - private function deleteAppListCommands(): void + private function deleteCommand(string $path): void { - if (is_file(APPPATH . 'Commands/ListCommands.php')) { - unlink(APPPATH . 'Commands/ListCommands.php'); + if (is_file(APPPATH . 'Commands/' . basename($path))) { + unlink(APPPATH . 'Commands/' . basename($path)); } + + clearstatcache(true); } public function testRunOnUnknownCommand(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:unknown', [])); + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:unknown', [])); $this->assertArrayNotHasKey('app:unknown', $commands->getCommands()); - $this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer()); + $this->assertSame("\nCommand \"app:unknown\" not found.\n", $this->getUndecoratedBuffer()); + + $this->resetStreamFilterBuffer(); + CLI::resetLastWrite(); + + $this->assertSame(EXIT_ERROR, $commands->runCommand('app:unknown', [], [])); + $this->assertArrayNotHasKey('app:unknown', $commands->getModernCommands()); + $this->assertSame("\nCommand \"app:unknown\" not found.\n", $this->getUndecoratedBuffer()); } - public function testRunOnUnknownCommandButWithOneAlternative(): void + public function testRunOnUnknownLegacyCommandButWithOneAlternative(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:inf', [])); + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:inf', [])); $this->assertSame( <<<'EOT' @@ -81,45 +115,269 @@ public function testRunOnUnknownCommandButWithOneAlternative(): void app:info EOT, - preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + $this->getUndecoratedBuffer(), ); } - public function testRunOnUnknownCommandButWithMultipleAlternatives(): void + public function testRunOnUnknownModernCommandButWithOneAlternative(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:', [])); + $this->assertSame(EXIT_ERROR, $commands->runCommand('app:ab', [], [])); + $this->assertSame( + <<<'EOT' + + Command "app:ab" not found. + + Did you mean this? + app:about + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnUnknownLegacyCommandButWithMultipleAlternatives(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:', [])); $this->assertSame( <<<'EOT' Command "app:" not found. Did you mean one of these? + app:about app:destructive app:info EOT, - preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnUnknownLegacyCommandAlsoSuggestsModernAlternatives(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:ab', [])); + $this->assertSame( + <<<'EOT' + + Command "app:ab" not found. + + Did you mean this? + app:about + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnUnknownModernCommandAlsoSuggestsLegacyAlternatives(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runCommand('app:inf', [], [])); + $this->assertSame( + <<<'EOT' + + Command "app:inf" not found. + + Did you mean this? + app:info + + EOT, + $this->getUndecoratedBuffer(), ); } - public function testRunOnAbstractCommandCannotBeRun(): void + public function testRunOnUnknownModernCommandButWithMultipleAlternatives(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:pablo', [])); + $this->assertSame(EXIT_ERROR, $commands->runCommand('clear', [], [])); + $this->assertSame( + <<<'EOT' + + Command "clear" not found. + + Did you mean one of these? + cache:clear + debugbar:clear + logs:clear + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnAbstractLegacyCommandCannotBeRun(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:pablo', [])); $this->assertArrayNotHasKey('app:pablo', $commands->getCommands()); - $this->assertStringContainsString('Command "app:pablo" not found', $this->getStreamFilterBuffer()); + $this->assertSame("\nCommand \"app:pablo\" not found.\n", $this->getUndecoratedBuffer()); } - public function testRunOnKnownCommand(): void + public function testRunOnKnownLegacyCommand(): void { $commands = new Commands(); - $this->assertSame(EXIT_SUCCESS, $commands->run('app:info', [])); + $this->assertSame(EXIT_SUCCESS, $commands->runLegacy('app:info', [])); $this->assertArrayHasKey('app:info', $commands->getCommands()); - $this->assertStringContainsString('CodeIgniter Version', $this->getStreamFilterBuffer()); + $this->assertSame( + sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION), + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnKnownModernCommand(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_SUCCESS, $commands->runCommand('app:about', ['a'], [])); + $this->assertArrayHasKey('app:about', $commands->getModernCommands()); + $this->assertSame( + sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION), + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnLegacyCommandReturningNullIsDeprecated(): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, commands must return an integer exit code. Last command "null:return" exited with null. Defaulting to EXIT_SUCCESS.'); + + (new Commands())->runLegacy('null:return', []); + } + + public function testRunMethodIsDeprecatedInFavorOfRunLegacy(): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, "CodeIgniter\\CLI\\Commands::run()" is deprecated. Use "CodeIgniter\\CLI\\Commands::runLegacy()" instead.'); + + (new Commands())->run('app:info', []); + } + + public function testDiscoveryWarnsWhenSameCommandNameExistsInBothRegistries(): void + { + $this->injectDuplicateLocator(); + + $message = wordwrap( + sprintf( + 'Warning: The "dup:test" command is defined as both legacy (%s) and modern (%s). The legacy command will be executed. Please rename or remove one.', + DuplicateLegacy::class, + DuplicateModern::class, + ), + CLI::getWidth(), + ); + + $commands = new Commands(); + + $this->assertSame("\n{$message}\n", $this->getUndecoratedBuffer()); + $this->assertArrayHasKey('dup:test', $commands->getCommands()); + $this->assertArrayHasKey('dup:test', $commands->getModernCommands()); + } + + public function testHasLegacyCommand(): void + { + $commands = new Commands(); + + $this->assertTrue($commands->hasLegacyCommand('app:info')); + $this->assertFalse($commands->hasLegacyCommand('app:about')); + $this->assertFalse($commands->hasLegacyCommand('app:unknown')); + } + + public function testHasModernCommand(): void + { + $commands = new Commands(); + + $this->assertTrue($commands->hasModernCommand('app:about')); + $this->assertFalse($commands->hasModernCommand('app:info')); + $this->assertFalse($commands->hasModernCommand('app:unknown')); + } + + public function testCollidingCommandNameIsDetectableFromBothRegistries(): void + { + $this->injectDuplicateLocator(); + + $commands = new Commands(); + + $this->assertTrue($commands->hasLegacyCommand('dup:test')); + $this->assertTrue($commands->hasModernCommand('dup:test')); + } + + public function testShadowedModernCommandAliasesAreNotRegistered(): void + { + $this->injectDuplicateLocator(); + + $commands = new Commands(); + + // The legacy command owns the name, so the shadowed modern command's + // alias is dropped: neither listed nor resolvable. + $this->assertSame([], $commands->getCommandAliases()); + $this->assertFalse($commands->hasModernCommand('dup:alias')); + } + + public function testModernCommandAliasesAreRegistered(): void + { + $aliases = (new Commands())->getCommandAliases(); + + $this->assertSame('fixture:aliased', $aliases['fixture:alias']); + $this->assertSame('fixture:aliased', $aliases['fa']); + } + + public function testHasModernCommandResolvesAliases(): void + { + $commands = new Commands(); + + $this->assertTrue($commands->hasModernCommand('fixture:alias')); + $this->assertTrue($commands->hasModernCommand('fa')); + } + + public function testGetCommandResolvesAliasToCanonicalCommand(): void + { + $command = (new Commands())->getCommand('fixture:alias'); + + $this->assertInstanceOf(AliasedCommand::class, $command); + $this->assertSame('fixture:aliased', $command->getName()); + } + + public function testRunCommandViaAlias(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_SUCCESS, $commands->runCommand('fa', [], [])); + $this->assertStringContainsString('Ran fixture:aliased.', $this->getStreamFilterBuffer()); + } + + public function testAliasClashingWithCommandNameFailsHard(): void + { + $this->injectAliasLocator([ + AliasTargetCommand::class => SUPPORTPATH . 'InvalidCommands/AliasTargetCommand.php', + AliasClashCommand::class => SUPPORTPATH . 'InvalidCommands/AliasClashCommand.php', + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Command alias "alias:target" of the "alias:source" command clashes with an existing command of the same name.'); + + new Commands(); + } + + public function testAliasClashingWithAnotherAliasFailsHard(): void + { + $this->injectAliasLocator([ + AliasClashCommand::class => SUPPORTPATH . 'InvalidCommands/AliasClashCommand.php', + AliasSecondClashCommand::class => SUPPORTPATH . 'InvalidCommands/AliasSecondClashCommand.php', + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Command alias "alias:target" of the "alias:source-two" command is already used as an alias of the "alias:source" command.'); + + new Commands(); } public function testDestructiveCommandIsNotRisky(): void @@ -129,6 +387,30 @@ public function testDestructiveCommandIsNotRisky(): void command('app:destructive'); } + public function testGetCommand(): void + { + $commands = new Commands(); + + $this->assertInstanceOf(AppInfo::class, $commands->getCommand('app:info', legacy: true)); + $this->assertInstanceOf(AppAboutCommand::class, $commands->getCommand('app:about')); + } + + public function testGetCommandOnUnknownLegacyCommand(): void + { + $this->expectException(CommandNotFoundException::class); + $this->expectExceptionMessage('Command "app:unknown" not found.'); + + (new Commands())->getCommand('app:unknown', legacy: true); + } + + public function testGetCommandOnUnknownModernCommand(): void + { + $this->expectException(CommandNotFoundException::class); + $this->expectExceptionMessage('Command "app:unknown" not found.'); + + (new Commands())->getCommand('app:unknown'); + } + public function testDiscoverCommandsDoNotRunTwice(): void { $locator = $this->createMock(FileLocatorInterface::class); @@ -136,18 +418,74 @@ public function testDiscoverCommandsDoNotRunTwice(): void ->expects($this->once()) ->method('listFiles') ->with('Commands/') - ->willReturn([SUPPORTPATH . 'Commands/AppInfo.php']); + ->willReturn([ + SUPPORTPATH . 'Commands/Legacy/AppInfo.php', + SUPPORTPATH . 'Commands/Modern/AppAboutCommand.php', + ]); $locator - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('findQualifiedNameFromPath') - ->with(SUPPORTPATH . 'Commands/AppInfo.php') - ->willReturn(AppInfo::class); + ->willReturnMap([ + [SUPPORTPATH . 'Commands/Legacy/AppInfo.php', AppInfo::class], + [SUPPORTPATH . 'Commands/Modern/AppAboutCommand.php', AppAboutCommand::class], + ]); Services::injectMock('locator', $locator); $commands = new Commands(); // discoverCommands will be called in the constructor $commands->discoverCommands(); } + public function testDiscoverySkipsModernCommandWithoutCommandAttribute(): void + { + $path = SUPPORTPATH . 'InvalidCommands/NoAttributeCommand.php'; + + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->expects($this->once()) + ->method('listFiles') + ->with('Commands/') + ->willReturn([$path]); + $locator + ->expects($this->once()) + ->method('findQualifiedNameFromPath') + ->with($path) + ->willReturn(NoAttributeCommand::class); + Services::injectMock('locator', $locator); + + $commands = new Commands(); + + $this->assertSame([], $commands->getModernCommands()); + $this->assertSame([], $commands->getCommands()); + } + + public function testDiscoveryLogsErrorWhenCommandAttributeFailsToInstantiate(): void + { + $path = SUPPORTPATH . 'InvalidCommands/EmptyCommandName.php'; + + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->expects($this->once()) + ->method('listFiles') + ->with('Commands/') + ->willReturn([$path]); + $locator + ->expects($this->once()) + ->method('findQualifiedNameFromPath') + ->with($path) + ->willReturn(EmptyCommandName::class); + Services::injectMock('locator', $locator); + + $logger = $this->createMock(Logger::class); + $logger + ->expects($this->once()) + ->method('error') + ->with($this->callback(static fn (string $message): bool => $message !== '')); + + $commands = new Commands($logger); + + $this->assertSame([], $commands->getModernCommands()); + } + public function testDiscoverCommandsWithNoFiles(): void { $locator = $this->createMock(FileLocatorInterface::class); @@ -164,15 +502,91 @@ public function testDiscoverCommandsWithNoFiles(): void new Commands(); } - public function testDiscoveredCommandsCanBeOverridden(): void + public function testVerifyCommandThrowsDeprecationWhenCommandsArrayIsPassed(): void { - $this->copyAppListCommands(); + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, the $commands parameter of CodeIgniter\CLI\Commands::verifyCommand() is no longer used.'); - command('list'); + $commands = new Commands(); + $commands->verifyCommand('app:info', $commands->getCommands()); + } - $this->assertStringContainsString('This is App\Commands\ListCommands', $this->getStreamFilterBuffer()); - $this->assertStringNotContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); + public function testGetCommandAlternativesThrowsDeprecationWhenCommandsArrayIsPassed(): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, the $collection parameter of CodeIgniter\CLI\Commands::getCommandAlternatives() is no longer used.'); - $this->deleteAppListCommands(); + $commands = new Commands(); + self::getPrivateMethodInvoker($commands, 'getCommandAlternatives')('app:inf', $commands->getCommands()); + } + + public function testDiscoveredLegacyCommandsCanBeOverridden(): void + { + $this->copyCommand(SUPPORTPATH . '_command/AppInfo.php'); + + command('app:info'); + + $this->assertStringContainsString('This is App\Commands\AppInfo', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('CodeIgniter Version:', $this->getStreamFilterBuffer()); + + $this->deleteCommand(SUPPORTPATH . '_command/AppInfo.php'); + } + + public function testDiscoveredModernCommandsCanBeOverridden(): void + { + $this->copyCommand(SUPPORTPATH . '_command/AppAboutCommand.php'); + + command('app:about a'); + + $this->assertStringContainsString('This is App\Commands\AppAboutCommand', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('CodeIgniter Version:', $this->getStreamFilterBuffer()); + + $this->deleteCommand(SUPPORTPATH . '_command/AppAboutCommand.php'); + } + + private function injectDuplicateLocator(): void + { + $legacyFile = (new ReflectionClass(DuplicateLegacy::class))->getFileName(); + $modernFile = (new ReflectionClass(DuplicateModern::class))->getFileName(); + + $locator = $this->getMockBuilder(FileLocator::class) + ->setConstructorArgs([service('autoloader')]) + ->onlyMethods(['listFiles', 'findQualifiedNameFromPath']) + ->getMock(); + $locator->expects($this->once()) + ->method('listFiles') + ->with('Commands/') + ->willReturn([$legacyFile, $modernFile]); + $locator->expects($this->exactly(2)) + ->method('findQualifiedNameFromPath') + ->willReturnMap([ + [$legacyFile, DuplicateLegacy::class], + [$modernFile, DuplicateModern::class], + ]); + Services::injectMock('locator', $locator); + } + + /** + * Partially mocks the real locator so `lang()` can still load language + * files while discovery is fed the given command fixtures. + * + * @param array $classToFile + */ + private function injectAliasLocator(array $classToFile): void + { + $map = []; + + foreach ($classToFile as $class => $file) { + $map[] = [$file, $class]; + } + + $locator = $this->getMockBuilder(FileLocator::class) + ->setConstructorArgs([service('autoloader')]) + ->onlyMethods(['listFiles', 'findQualifiedNameFromPath']) + ->getMock(); + $locator->method('listFiles')->with('Commands/')->willReturn(array_values($classToFile)); + $locator->method('findQualifiedNameFromPath')->willReturnMap($map); + + Services::injectMock('locator', $locator); } } diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index ff4ac02ab29b..1923d9629798 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -22,11 +22,13 @@ use CodeIgniter\Test\Mock\MockCLIConfig; use CodeIgniter\Test\Mock\MockCodeIgniter; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[CoversClass(Console::class)] #[Group('Others')] final class ConsoleTest extends CIUnitTestCase { @@ -39,13 +41,7 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); CLI::init(); - $env = new DotEnv(ROOTPATH); - $env->load(); - - // Set environment values that would otherwise stop the framework from functioning during tests. - if (service('superglobals')->server('app.baseURL') === null) { - service('superglobals')->setServer('app.baseURL', 'http://example.com/'); - } + (new DotEnv(ROOTPATH))->load(); $this->app = new MockCodeIgniter(new MockCLIConfig()); $this->app->initialize(); @@ -53,39 +49,38 @@ protected function setUp(): void protected function tearDown(): void { - CLI::reset(); - parent::tearDown(); + + CLI::reset(); } - public function testHeader(): void + public function testHeaderShowsNormally(): void { - $console = new Console(); - $console->showHeader(); - $this->assertGreaterThan( - 0, - strpos( - $this->getStreamFilterBuffer(), - sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), - ), + $this->initializeConsole(); + (new Console())->run(); + + $this->assertStringContainsString( + sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), ); } - public function testNoHeader(): void + public function testHeaderDoesNotShowOnNoHeader(): void { - $console = new Console(); - $console->showHeader(true); - $this->assertSame('', $this->getStreamFilterBuffer()); + $this->initializeConsole('--no-header'); + (new Console())->run(); + + $this->assertStringNotContainsString( + sprintf('CodeIgniter v%s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); } public function testRun(): void { - $this->initCLI(); - - $console = new Console(); - $console->run(); + $this->initializeConsole(); + (new Console())->run(); - // make sure the result looks like a command list $this->assertStringContainsString('Lists the available commands.', $this->getStreamFilterBuffer()); $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); } @@ -97,10 +92,8 @@ public function testRunEventsPreCommand(): void $result = 'fired'; }); - $this->initCLI(); - - $console = new Console(); - $console->run(); + $this->initializeConsole(); + (new Console())->run(); $this->assertEventTriggered('pre_command'); $this->assertSame('fired', $result); @@ -113,10 +106,8 @@ public function testRunEventsPostCommand(): void $result = 'fired'; }); - $this->initCLI(); - - $console = new Console(); - $console->run(); + $this->initializeConsole(); + (new Console())->run(); $this->assertEventTriggered('post_command'); $this->assertSame('fired', $result); @@ -124,23 +115,17 @@ public function testRunEventsPostCommand(): void public function testBadCommand(): void { - $this->initCLI('bogus'); - - $console = new Console(); - $console->run(); + $this->initializeConsole('bogus'); + (new Console())->run(); - // make sure the result looks like a command list $this->assertStringContainsString('Command "bogus" not found', $this->getStreamFilterBuffer()); } public function testHelpCommandDetails(): void { - $this->initCLI('help', 'make:migration'); - - $console = new Console(); - $console->run(); + $this->initializeConsole('help', 'make:migration'); + (new Console())->run(); - // make sure the result looks like more detailed help $this->assertStringContainsString('Description:', $this->getStreamFilterBuffer()); $this->assertStringContainsString('Usage:', $this->getStreamFilterBuffer()); $this->assertStringContainsString('Options:', $this->getStreamFilterBuffer()); @@ -148,8 +133,7 @@ public function testHelpCommandDetails(): void public function testHelpCommandUsingHelpOption(): void { - $this->initCLI('env', '--help'); - + $this->initializeConsole('env', '--help'); (new Console())->run(); $this->assertStringContainsString('env []', $this->getStreamFilterBuffer()); @@ -161,8 +145,7 @@ public function testHelpCommandUsingHelpOption(): void public function testHelpOptionIsOnlyPassed(): void { - $this->initCLI('--help'); - + $this->initializeConsole('--help'); (new Console())->run(); // Since calling `php spark` is the same as calling `php spark list`, @@ -170,23 +153,61 @@ public function testHelpOptionIsOnlyPassed(): void $this->assertStringContainsString('Lists the available commands.', $this->getStreamFilterBuffer()); } - public function testHelpArgumentAndHelpOptionCombined(): void + public function testHelpShortcutStripsOptionsMeantForTargetCommand(): void { - $this->initCLI('help', '--help'); + $this->initializeConsole('serve', '--host=example.com', '--help'); + $exitCode = (new Console())->run(); + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('serve [options]', $this->getStreamFilterBuffer()); + } + + public function testHelpArgumentAndHelpOptionCombined(): void + { + $this->initializeConsole('help', '--help'); (new Console())->run(); // Same as calling `php spark help` only $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); } - /** - * @param string ...$command - */ - protected function initCLI(...$command): void + public function testRunRoutesDiscoveredLegacyCommandThroughRunLegacy(): void + { + // `app:info` is a legacy BaseCommand fixture. Console must take the + // legacy branch of run() and delegate to Commands::runLegacy(). + $this->initializeConsole('app:info'); + $exitCode = (new Console())->run(); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString( + sprintf('CodeIgniter Version: %s', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); + } + + public function testConsoleReturnsTheLastExecutedCommand(): void + { + $console = new Console(); + $this->assertSame('', $console->getCommand()); + + $this->initializeConsole(); + $console->run(); + $this->assertSame('list', $console->getCommand()); + + $this->initializeConsole('help'); + $console->run(); + $this->assertSame('help', $console->getCommand()); + + $this->initializeConsole('list'); + $console->run(); + $this->assertSame('list', $console->getCommand()); + } + + private function initializeConsole(string ...$tokens): void { - service('superglobals')->setServer('argv', ['spark', ...$command]); - service('superglobals')->setServer('argc', count(service('superglobals')->server('argv'))); + service('superglobals') + ->setServer('argv', ['spark', ...$tokens]) + ->setServer('argc', count($tokens) + 1); CLI::init(); } diff --git a/tests/system/CLI/Input/ArgumentTest.php b/tests/system/CLI/Input/ArgumentTest.php new file mode 100644 index 000000000000..088851dcbf88 --- /dev/null +++ b/tests/system/CLI/Input/ArgumentTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Argument::class)] +#[Group('Others')] +final class ArgumentTest extends CIUnitTestCase +{ + public function testBasicArgumentExposesProperties(): void + { + $argument = new Argument( + name: 'path', + description: 'The path to operate on.', + required: true, + ); + + $this->assertSame('path', $argument->name); + $this->assertSame('The path to operate on.', $argument->description); + $this->assertTrue($argument->required); + $this->assertFalse($argument->isArray); + $this->assertNull($argument->default); + } + + public function testArrayArgumentDefaultsToEmptyArrayWhenOmitted(): void + { + $argument = new Argument(name: 'tags', isArray: true); + + $this->assertTrue($argument->isArray); + $this->assertFalse($argument->required); + $this->assertSame([], $argument->default); + } + + public function testArrayArgumentRetainsExplicitDefault(): void + { + $argument = new Argument(name: 'tags', isArray: true, default: ['a', 'b']); + + $this->assertSame(['a', 'b'], $argument->default); + } + + public function testOptionalArgumentRetainsStringDefault(): void + { + $argument = new Argument(name: 'driver', default: 'file'); + + $this->assertFalse($argument->required); + $this->assertSame('file', $argument->default); + } + + /** + * @param array $parameters + */ + #[DataProvider('provideInvalidDefinitionsAreRejected')] + public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void + { + $this->expectException(InvalidArgumentDefinitionException::class); + $this->expectExceptionMessage($message); + + new Argument(...$parameters); + } + + /** + * @return iterable}> + */ + public static function provideInvalidDefinitionsAreRejected(): iterable + { + yield 'empty name' => [ + 'Argument name cannot be empty.', + ['name' => ''], + ]; + + yield 'invalid name' => [ + 'Argument name "invalid name" is not valid.', + ['name' => 'invalid name'], + ]; + + yield 'reserved name' => [ + 'Argument name "extra_arguments" is reserved and cannot be used.', + ['name' => 'extra_arguments'], + ]; + + yield 'required array argument' => [ + 'Array argument "test" cannot be required.', + ['name' => 'test', 'required' => true, 'isArray' => true], + ]; + + yield 'required argument with default value' => [ + 'Argument "test" is required and must not have a default value.', + ['name' => 'test', 'required' => true, 'default' => 'value'], + ]; + + yield 'optional argument with null default value' => [ + 'Argument "test" is optional and must have a default value.', + ['name' => 'test'], + ]; + + yield 'array argument with non-array default value' => [ + 'Array argument "test" must have an array default value or null.', + ['name' => 'test', 'isArray' => true, 'default' => 'value'], + ]; + + yield 'non-array argument with array default value' => [ + 'Argument "test" does not accept an array default value.', + ['name' => 'test', 'default' => ['value']], + ]; + } +} diff --git a/tests/system/CLI/Input/OptionTest.php b/tests/system/CLI/Input/OptionTest.php new file mode 100644 index 000000000000..97d4ef5799c9 --- /dev/null +++ b/tests/system/CLI/Input/OptionTest.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Option::class)] +#[Group('Others')] +final class OptionTest extends CIUnitTestCase +{ + public function testFlagOptionExposesDefaults(): void + { + $option = new Option(name: 'verbose', shortcut: 'v', description: 'Verbose output.'); + + $this->assertSame('verbose', $option->name); + $this->assertSame('v', $option->shortcut); + $this->assertSame('Verbose output.', $option->description); + $this->assertFalse($option->acceptsValue); + $this->assertFalse($option->requiresValue); + $this->assertFalse($option->isArray); + $this->assertFalse($option->negatable); + $this->assertNull($option->valueLabel); + $this->assertNull($option->negation); + $this->assertFalse($option->default); + } + + public function testLeadingDoubleDashIsStrippedFromName(): void + { + $option = new Option(name: '--force'); + + $this->assertSame('force', $option->name); + } + + public function testLeadingDashIsStrippedFromShortcut(): void + { + $option = new Option(name: 'force', shortcut: '-f'); + + $this->assertSame('f', $option->shortcut); + } + + public function testRequiresValueImpliesAcceptsValue(): void + { + $option = new Option(name: 'path', requiresValue: true, default: '/tmp'); + + $this->assertTrue($option->acceptsValue); + $this->assertTrue($option->requiresValue); + $this->assertSame('/tmp', $option->default); + } + + public function testIsArrayImpliesAcceptsValue(): void + { + $option = new Option(name: 'tags', requiresValue: true, isArray: true, default: ['a']); + + $this->assertTrue($option->acceptsValue); + $this->assertTrue($option->isArray); + $this->assertSame(['a'], $option->default); + } + + public function testValueLabelDefaultsToName(): void + { + $option = new Option(name: 'path', acceptsValue: true); + + $this->assertSame('path', $option->valueLabel); + } + + public function testValueLabelCanBeCustomized(): void + { + $option = new Option(name: 'path', acceptsValue: true, valueLabel: 'file'); + + $this->assertSame('file', $option->valueLabel); + } + + public function testNegatableOptionComputesNegation(): void + { + $option = new Option(name: 'force', negatable: true, default: false); + + $this->assertTrue($option->negatable); + $this->assertSame('no-force', $option->negation); + $this->assertFalse($option->default); + } + + public function testNegatableOptionAcceptsBooleanDefault(): void + { + $option = new Option(name: 'force', negatable: true, default: true); + + $this->assertTrue($option->default); + } + + /** + * @param array $parameters + */ + #[DataProvider('provideInvalidDefinitionsAreRejected')] + public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void + { + $this->expectException(InvalidOptionDefinitionException::class); + $this->expectExceptionMessage($message); + + new Option(...$parameters); + } + + /** + * @return iterable}> + */ + public static function provideInvalidDefinitionsAreRejected(): iterable + { + yield 'empty name' => [ + 'Option name cannot be empty.', + ['name' => ''], + ]; + + yield 'double dash only' => [ + 'Option name cannot be empty.', + ['name' => '--'], + ]; + + yield 'single dash only' => [ + 'Option name "---" is not valid.', + ['name' => '-'], + ]; + + yield 'reserved name' => [ + 'Option name "--extra_options" is reserved and cannot be used.', + ['name' => 'extra_options'], + ]; + + yield 'empty shortcut name' => [ + 'Shortcut name cannot be empty.', + ['name' => 'test', 'shortcut' => ''], + ]; + + yield 'single dash only shortcut' => [ + 'Shortcut name cannot be empty.', + ['name' => 'test', 'shortcut' => '-'], + ]; + + yield 'invalid shortcut name' => [ + 'Shortcut name "-:" is not valid.', + ['name' => 'test', 'shortcut' => '-:'], + ]; + + yield 'shortcut name with more than one character' => [ + 'Shortcut name "-ab" must be a single character.', + ['name' => 'test', 'shortcut' => '-ab'], + ]; + + yield 'negatable option accepting value' => [ + 'Negatable option "--test" cannot be defined to accept a value.', + ['name' => 'test', 'acceptsValue' => true, 'negatable' => true], + ]; + + yield 'array option not requiring value' => [ + 'Array option "--test" must require a value.', + ['name' => 'test', 'isArray' => true], + ]; + + yield 'option requiring value but has no default value' => [ + 'Option "--test" requires a string default value.', + ['name' => 'test', 'requiresValue' => true], + ]; + + yield 'negatable option cannot be array' => [ + 'Negatable option "--test" cannot be defined as an array.', + ['name' => 'test', 'isArray' => true, 'negatable' => true], + ]; + + yield 'option not accepting value but has default value' => [ + 'Option "--test" does not accept a value and cannot have a default value.', + ['name' => 'test', 'default' => 'value'], + ]; + + yield 'negatable option with non-boolean default value' => [ + 'Negatable option "--test" must have a boolean default value.', + ['name' => 'test', 'negatable' => true, 'default' => 'value'], + ]; + + yield 'negatable option with no default value' => [ + 'Negatable option "--test" must have a boolean default value.', + ['name' => 'test', 'negatable' => true], + ]; + + yield 'array option with non-array default value' => [ + 'Array option "--test" must have an array default value or null.', + ['name' => 'test', 'requiresValue' => true, 'isArray' => true, 'default' => 'value'], + ]; + + yield 'array option with empty array default value' => [ + 'Array option "--test" cannot have an empty array as the default value.', + ['name' => 'test', 'requiresValue' => true, 'isArray' => true, 'default' => []], + ]; + } +} diff --git a/tests/system/CLI/NullInputOutputTest.php b/tests/system/CLI/NullInputOutputTest.php new file mode 100644 index 000000000000..4008bc7bf6ff --- /dev/null +++ b/tests/system/CLI/NullInputOutputTest.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\CLI; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class NullInputOutputTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + public function testFwriteDiscardsOutput(): void + { + $io = new NullInputOutput(); + $io->fwrite(STDOUT, 'should not appear'); + $io->fwrite(STDERR, 'should not appear either'); + + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testInputReturnsEmptyStringWithoutEchoingPrefix(): void + { + $io = new NullInputOutput(); + + $this->assertSame('', $io->input()); + $this->assertSame('', $io->input('any prefix > ')); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testCanBeSwappedIntoCliToSilenceWrites(): void + { + $prior = CLI::getInputOutput(); + CLI::setInputOutput(new NullInputOutput()); + + try { + CLI::write('this should be discarded'); + CLI::error('this too'); + $this->assertSame('', $this->getStreamFilterBuffer()); + } finally { + if ($prior instanceof InputOutput) { + CLI::setInputOutput($prior); + } else { + CLI::resetInputOutput(); + } + } + } +} diff --git a/tests/system/CLI/SignalTest.php b/tests/system/CLI/SignalTest.php index 8e444895f226..ab92a135ebb5 100644 --- a/tests/system/CLI/SignalTest.php +++ b/tests/system/CLI/SignalTest.php @@ -17,9 +17,9 @@ 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; +use Tests\Support\Commands\Legacy\SignalCommand; +use Tests\Support\Commands\Legacy\SignalCommandNoPcntl; +use Tests\Support\Commands\Legacy\SignalCommandNoPosix; /** * @internal diff --git a/tests/system/Cache/Handlers/ApcuHandlerTest.php b/tests/system/Cache/Handlers/ApcuHandlerTest.php index 5d5d5f7b2f6f..5804bdd0424e 100644 --- a/tests/system/Cache/Handlers/ApcuHandlerTest.php +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Cache\Handlers; +use ArgumentCountError; use CodeIgniter\Cache\CacheFactory; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; @@ -92,6 +93,48 @@ public function testRemember(): void $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 testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 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)); + } + + /** + * 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 testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(ArgumentCountError::class); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); diff --git a/tests/system/Cache/Handlers/DummyHandlerTest.php b/tests/system/Cache/Handlers/DummyHandlerTest.php index ab3b3ccd643d..2bdc42b5bd5d 100644 --- a/tests/system/Cache/Handlers/DummyHandlerTest.php +++ b/tests/system/Cache/Handlers/DummyHandlerTest.php @@ -48,6 +48,20 @@ public function testRemember(): void $this->assertNull($dummyHandler); } + public function testRememberWithTTLCallable(): void + { + $dummyHandler = $this->handler->remember('key', static fn (): int => 2, static fn (): string => 'value'); + + $this->assertNull($dummyHandler); + } + + public function testRememberWithTTLCallableAndValuePassed(): void + { + $dummyHandler = $this->handler->remember('key', static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertNull($dummyHandler); + } + public function testSave(): void { $this->assertTrue($this->handler->save('key', 'value')); diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index c4fa8481bbd4..b6bb246d112c 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Cache\Handlers; +use ArgumentCountError; use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\CLI\CLI; @@ -144,6 +145,48 @@ public function testRemember(): void $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 testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 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)); + } + + /** + * 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 testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(ArgumentCountError::class); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + /** * chmod('path', 0444) does not work on Windows */ diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index e6bd5dd147ec..6a667405497d 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -13,7 +13,10 @@ namespace CodeIgniter\Cache\Handlers; +use ArgumentCountError; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\I18n\Time; @@ -51,6 +54,10 @@ protected function setUp(): void protected function tearDown(): void { + if (! isset($this->handler)) { + return; + } + foreach (self::getKeyArray() as $key) { $this->handler->delete($key); } @@ -95,11 +102,100 @@ public function testRemember(): void $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 testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 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)); + } + + /** + * 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 testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(ArgumentCountError::class); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); } + public function testLockOperations(): void + { + $handler = $this->handler; + + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); + + $store = $handler->lockStore(); + + $this->assertInstanceOf(LockStoreInterface::class, $store); + $this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60)); + $this->assertFalse($store->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner1', $store->getLockOwner(self::$key1)); + $this->assertFalse($store->releaseLock(self::$key1, 'owner2')); + $this->assertFalse($store->refreshLock(self::$key1, 'owner2', 120)); + $this->assertTrue($store->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($store->releaseLock(self::$key1, 'owner1')); + $this->assertNull($store->getLockOwner(self::$key1)); + $this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60)); + $this->assertTrue($store->forceReleaseLock(self::$key1)); + $this->assertNull($store->getLockOwner(self::$key1)); + $this->assertTrue($store->forceReleaseLock(self::$key1)); + } + + /** + * This test waits for 2 seconds before reacquiring the lock. + * + * @timeLimit 2.5 + */ + public function testExpiredLockCanBeAcquiredByNewOwner(): void + { + $handler = $this->handler; + + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); + + $store = $handler->lockStore(); + + $this->assertTrue($store->acquireLock(self::$key1, 'owner1', 1)); + + CLI::wait(2); + + $this->assertTrue($store->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner2', $store->getLockOwner(self::$key1)); + $this->assertFalse($store->releaseLock(self::$key1, 'owner1')); + $this->assertFalse($store->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($store->releaseLock(self::$key1, 'owner2')); + } + public function testSavePermanent(): void { $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); @@ -196,11 +292,18 @@ public function testPing(): void public function testReconnect(): void { + $handler = $this->handler; + + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); + + $lockStore = $handler->lockStore(); + $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)); + $this->assertNotSame($lockStore, $handler->lockStore()); } } diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 135d3ff083de..3e6a41e90e65 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -13,7 +13,10 @@ namespace CodeIgniter\Cache\Handlers; +use ArgumentCountError; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -45,10 +48,18 @@ protected function setUp(): void $this->config = new Cache(); $this->handler = CacheFactory::getHandler($this->config, 'predis'); + + if ($this->handler::class !== PredisHandler::class) { + $this->markTestSkipped('Predis connection not available.'); + } } protected function tearDown(): void { + if (! isset($this->handler)) { + return; + } + foreach (self::getKeyArray() as $key) { $this->handler->delete($key); } @@ -99,11 +110,72 @@ public function testRemember(): void $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 testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 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)); + } + + /** + * 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 testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(ArgumentCountError::class); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); } + public function testLockOperations(): void + { + $handler = $this->handler; + + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); + + $store = $handler->lockStore(); + + $this->assertInstanceOf(LockStoreInterface::class, $store); + $this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60)); + $this->assertFalse($store->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner1', $store->getLockOwner(self::$key1)); + $this->assertFalse($store->releaseLock(self::$key1, 'owner2')); + $this->assertTrue($store->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($store->releaseLock(self::$key1, 'owner1')); + $this->assertNull($store->getLockOwner(self::$key1)); + $this->assertTrue($store->forceReleaseLock(self::$key1)); + } + public function testSavePermanent(): void { $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); @@ -199,11 +271,18 @@ public function testPing(): void public function testReconnect(): void { - $this->handler->save(self::$key1, 'value'); - $this->assertSame('value', $this->handler->get(self::$key1)); + $handler = $this->handler; - $this->assertTrue($this->handler->reconnect()); + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); - $this->assertSame('value', $this->handler->get(self::$key1)); + $lockStore = $handler->lockStore(); + + $handler->save(self::$key1, 'value'); + $this->assertSame('value', $handler->get(self::$key1)); + + $this->assertTrue($handler->reconnect()); + + $this->assertSame('value', $handler->get(self::$key1)); + $this->assertNotSame($lockStore, $handler->lockStore()); } } diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index d42123c6dd82..5c561e399ed2 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -13,7 +13,10 @@ namespace CodeIgniter\Cache\Handlers; +use ArgumentCountError; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; use CodeIgniter\CLI\CLI; use CodeIgniter\I18n\Time; use Config\Cache; @@ -54,6 +57,10 @@ protected function setUp(): void protected function tearDown(): void { + if (! isset($this->handler)) { + return; + } + foreach (self::getKeyArray() as $key) { $this->handler->delete($key); } @@ -104,11 +111,72 @@ public function testRemember(): void $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 testRememberWithTTLCallable(): void + { + $this->handler->remember(self::$key1, static fn (): int => 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)); + } + + /** + * 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 testRememberWithTTLCallableAndValuePassed(): void + { + $this->handler->remember(self::$key1, static fn ($value): int => $value[0], static fn (): array => [2, 3]); + + $this->assertSame([2, 3], $this->handler->get(self::$key1)); + $this->assertNull($this->handler->get(self::$dummy)); + + CLI::wait(3); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testRememberWithTTLCallableAndMultipleParameters(): void + { + $this->expectException(ArgumentCountError::class); + + /** @phpstan-ignore argument.type */ + $this->handler->remember(self::$key1, static fn ($a, $b): int => 2, static fn (): string => 'value'); + } + public function testSave(): void { $this->assertTrue($this->handler->save(self::$key1, 'value')); } + public function testLockOperations(): void + { + $handler = $this->handler; + + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); + + $store = $handler->lockStore(); + + $this->assertInstanceOf(LockStoreInterface::class, $store); + $this->assertTrue($store->acquireLock(self::$key1, 'owner1', 60)); + $this->assertFalse($store->acquireLock(self::$key1, 'owner2', 60)); + $this->assertSame('owner1', $store->getLockOwner(self::$key1)); + $this->assertFalse($store->releaseLock(self::$key1, 'owner2')); + $this->assertTrue($store->refreshLock(self::$key1, 'owner1', 120)); + $this->assertTrue($store->releaseLock(self::$key1, 'owner1')); + $this->assertNull($store->getLockOwner(self::$key1)); + $this->assertTrue($store->forceReleaseLock(self::$key1)); + } + public function testSavePermanent(): void { $this->assertTrue($this->handler->save(self::$key1, 'value', 0)); @@ -241,11 +309,18 @@ public function testPing(): void public function testReconnect(): void { - $this->handler->save(self::$key1, 'value'); - $this->assertSame('value', $this->handler->get(self::$key1)); + $handler = $this->handler; - $this->assertTrue($this->handler->reconnect()); + $this->assertInstanceOf(LockStoreProviderInterface::class, $handler); - $this->assertSame('value', $this->handler->get(self::$key1)); + $lockStore = $handler->lockStore(); + + $handler->save(self::$key1, 'value'); + $this->assertSame('value', $handler->get(self::$key1)); + + $this->assertTrue($handler->reconnect()); + + $this->assertSame('value', $handler->get(self::$key1)); + $this->assertNotSame($lockStore, $handler->lockStore()); } } diff --git a/tests/system/Cache/ResponseCacheTest.php b/tests/system/Cache/ResponseCacheTest.php index a05d37d96f54..73cc3e5a7a78 100644 --- a/tests/system/Cache/ResponseCacheTest.php +++ b/tests/system/Cache/ResponseCacheTest.php @@ -87,7 +87,7 @@ public function testCachePageIncomingRequest(): void { $pageCache = $this->createResponseCache(); - $response = new Response(new App()); + $response = new Response(); $response->setHeader('ETag', 'abcd1234'); $response->setBody('The response body.'); @@ -97,7 +97,7 @@ public function testCachePageIncomingRequest(): void )); // Check cache with a request with the same URI path. - $cachedResponse = $pageCache->get($this->createIncomingRequest('foo/bar'), new Response(new App())); + $cachedResponse = $pageCache->get($this->createIncomingRequest('foo/bar'), new Response()); $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); $this->assertSame('The response body.', $cachedResponse->getBody()); $this->assertSame('abcd1234', $cachedResponse->getHeaderLine('ETag')); @@ -105,14 +105,14 @@ public function testCachePageIncomingRequest(): void // Check cache with a request with the same URI path and different query string. $cachedResponse = $pageCache->get( $this->createIncomingRequest('foo/bar', ['foo' => 'bar', 'bar' => 'baz']), - new Response(new App()), + new Response(), ); $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); $this->assertSame('The response body.', $cachedResponse->getBody()); $this->assertSame('abcd1234', $cachedResponse->getHeaderLine('ETag')); // Check cache with another request with the different URI path. - $cachedResponse = $pageCache->get($this->createIncomingRequest('another'), new Response(new App())); + $cachedResponse = $pageCache->get($this->createIncomingRequest('another'), new Response()); $this->assertNotInstanceOf(ResponseInterface::class, $cachedResponse); } @@ -120,14 +120,14 @@ public function testCachePageIncomingRequestWithStatus(): void { $pageCache = $this->createResponseCache(); - $response = new Response(new App()); + $response = new Response(); $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())); + $cachedResponse = $pageCache->get($this->createIncomingRequest('foo/bar'), new Response()); $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); $this->assertSame(432, $cachedResponse->getStatusCode()); $this->assertSame('Foo Bar', $cachedResponse->getReasonPhrase()); @@ -143,7 +143,7 @@ public function testCachePageIncomingRequestWithCacheQueryString(): void $request = $this->createIncomingRequest('foo/bar', ['foo' => 'bar', 'bar' => 'baz']); - $response = new Response(new App()); + $response = new Response(); $response->setHeader('ETag', 'abcd1234'); $response->setBody('The response body.'); @@ -152,7 +152,7 @@ public function testCachePageIncomingRequestWithCacheQueryString(): void // Check cache with a request with the same URI path and same query string. $cachedResponse = $pageCache->get( $this->createIncomingRequest('foo/bar', ['foo' => 'bar', 'bar' => 'baz']), - new Response(new App()), + new Response(), ); $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); $this->assertSame('The response body.', $cachedResponse->getBody()); @@ -161,12 +161,12 @@ public function testCachePageIncomingRequestWithCacheQueryString(): void // Check cache with a request with the same URI path and different query string. $cachedResponse = $pageCache->get( $this->createIncomingRequest('foo/bar', ['xfoo' => 'bar', 'bar' => 'baz']), - new Response(new App()), + new Response(), ); $this->assertNotInstanceOf(ResponseInterface::class, $cachedResponse); // Check cache with another request with the different URI path. - $cachedResponse = $pageCache->get($this->createIncomingRequest('another'), new Response(new App())); + $cachedResponse = $pageCache->get($this->createIncomingRequest('another'), new Response()); $this->assertNotInstanceOf(ResponseInterface::class, $cachedResponse); } @@ -174,7 +174,7 @@ public function testCachePageIncomingRequestWithHttpMethods(): void { $pageCache = $this->createResponseCache(); - $response = new Response(new App()); + $response = new Response(); $response->setBody('The response body.'); $this->assertTrue($pageCache->make($this->createIncomingRequest('foo/bar'), $response)); @@ -182,7 +182,7 @@ public function testCachePageIncomingRequestWithHttpMethods(): void // Check cache with a request with the same URI path and different HTTP method $cachedResponse = $pageCache->get( $this->createIncomingRequest('foo/bar')->withMethod('POST'), - new Response(new App()), + new Response(), ); $this->assertNotInstanceOf(ResponseInterface::class, $cachedResponse); } @@ -191,18 +191,18 @@ public function testCachePageCLIRequest(): void { $pageCache = $this->createResponseCache(); - $response = new Response(new App()); + $response = new Response(); $response->setBody('The response body.'); $this->assertTrue($pageCache->make($this->createCLIRequest(['foo', 'bar']), $response)); // Check cache with a request with the same params. - $cachedResponse = $pageCache->get($this->createCLIRequest(['foo', 'bar']), new Response(new App())); + $cachedResponse = $pageCache->get($this->createCLIRequest(['foo', 'bar']), new Response()); $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); $this->assertSame('The response body.', $cachedResponse->getBody()); // Check cache with another request with the different params. - $cachedResponse = $pageCache->get($this->createCLIRequest(['baz']), new Response(new App())); + $cachedResponse = $pageCache->get($this->createCLIRequest(['baz']), new Response()); $this->assertNotInstanceOf(ResponseInterface::class, $cachedResponse); } @@ -217,7 +217,7 @@ public function testUnserializeError(): void $request = $this->createIncomingRequest('foo/bar'); - $response = new Response(new App()); + $response = new Response(); $response->setHeader('ETag', 'abcd1234'); $response->setBody('The response body.'); @@ -229,7 +229,7 @@ public function testUnserializeError(): void $mockCache->save($cacheKey, 'Invalid data'); // Check cache with a request with the same URI path. - $pageCache->get($request, new Response(new App())); + $pageCache->get($request, new Response()); } public function testInvalidCacheError(): void @@ -243,7 +243,7 @@ public function testInvalidCacheError(): void $request = $this->createIncomingRequest('foo/bar'); - $response = new Response(new App()); + $response = new Response(); $response->setHeader('ETag', 'abcd1234'); $response->setBody('The response body.'); @@ -255,6 +255,6 @@ public function testInvalidCacheError(): void $mockCache->save($cacheKey, serialize(['a' => '1'])); // Check cache with a request with the same URI path. - $pageCache->get($request, new Response(new App())); + $pageCache->get($request, new Response()); } } diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index e5d371bfb941..0feb97f555ca 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -790,12 +790,9 @@ public function testPageCacheSendSecureHeaders(): void $routes = service('routes'); $routes->add('test', static function () { - CodeIgniter::cache(3600); + service('responsecache')->setTtl(3600); - $response = service('response'); - $string = 'This is a test page. Elapsed time: {elapsed_time}'; - - return $response->setBody($string); + return service('response')->setBody('This is a test page. Elapsed time: {elapsed_time}'); }); $router = service('router', $routes, service('incomingrequest')); Services::injectMock('router', $router); @@ -1309,4 +1306,26 @@ public function testResetForWorkerMode(): void $this->assertSame($csp->getStyleNonce(), RichRenderer::$css_nonce); $this->assertTrue(RichRenderer::$needs_pre_render); } + + public function testResetForWorkerModeDoesNotLoadCspWhenDisabled(): void + { + $this->resetServices(); + + config(App::class)->CSPEnabled = false; + + RichRenderer::$js_nonce = 'stale-script-nonce'; + RichRenderer::$css_nonce = 'stale-style-nonce'; + RichRenderer::$needs_pre_render = false; + + $codeigniter = new MockCodeIgniter(new App()); + + $this->assertFalse(Services::has('csp')); + + $codeigniter->resetForWorkerMode(); + + $this->assertFalse(Services::has('csp')); + $this->assertNull(RichRenderer::$js_nonce); + $this->assertNull(RichRenderer::$css_nonce); + $this->assertTrue(RichRenderer::$needs_pre_render); + } } diff --git a/tests/system/Commands/Cache/ClearCacheTest.php b/tests/system/Commands/Cache/ClearCacheTest.php index a5679b00b3d9..9a2f5b53b611 100644 --- a/tests/system/Commands/Cache/ClearCacheTest.php +++ b/tests/system/Commands/Cache/ClearCacheTest.php @@ -66,7 +66,10 @@ public function testClearCacheWorks(): void command('cache:clear'); $this->assertNull(cache('foo')); - $this->assertStringContainsString('Cache cleared.', $this->getStreamFilterBuffer()); + $this->assertStringContainsString( + sprintf('Cache cleared using the "%s" driver.', config('Cache')->handler), + $this->getStreamFilterBuffer(), + ); } public function testClearCacheFails(): void @@ -83,7 +86,7 @@ public function testClearCacheFails(): void Services::resetSingle('cache'); $this->assertSame( - "\nError while clearing the cache.\n", + sprintf("\nError occurred while clearing the cache using the \"%s\" driver.\n", config('Cache')->handler), preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), ); } diff --git a/tests/system/Commands/Cache/InfoCacheTest.php b/tests/system/Commands/Cache/InfoCacheTest.php index 44aa389338cc..7516707ae066 100644 --- a/tests/system/Commands/Cache/InfoCacheTest.php +++ b/tests/system/Commands/Cache/InfoCacheTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Commands\Cache; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use Config\Services; @@ -31,28 +32,39 @@ protected function setUp(): void { parent::setUp(); - // Make sure we are testing with the correct handler (override injections) + CLI::resetLastWrite(); + $this->resetServices(); Services::injectMock('cache', CacheFactory::getHandler(config('Cache'))); } protected function tearDown(): void { - // restore default cache handler - config('Cache')->handler = 'file'; + parent::tearDown(); + + CLI::resetLastWrite(); + $this->resetServices(); + $this->resetFactories(); } - protected function getBuffer(): string + private function getUndecoratedBuffer(): string { - return $this->getStreamFilterBuffer(); + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; } public function testInfoCacheErrorsOnInvalidHandler(): void { config('Cache')->handler = 'redis'; - cache()->save('foo', 'bar'); + command('cache:info'); - $this->assertStringContainsString('This command only supports the file cache handler.', $this->getBuffer()); + $this->assertSame( + <<<'EOT' + + This command only supports the file cache handler. The configured handler is "redis". + + EOT, + $this->getUndecoratedBuffer(), + ); } public function testInfoCacheCanSeeFoo(): void @@ -60,24 +72,29 @@ public function testInfoCacheCanSeeFoo(): void cache()->save('foo', 'bar'); command('cache:info'); - $this->assertStringContainsString('foo', $this->getBuffer()); + $this->assertStringContainsString('foo', $this->getStreamFilterBuffer()); } - public function testInfoCacheCanSeeTable(): void + public function testInfoCacheCanSeeTheads(): void { command('cache:info'); - $this->assertStringContainsString('Name', $this->getBuffer()); - $this->assertStringContainsString('Server Path', $this->getBuffer()); - $this->assertStringContainsString('Size', $this->getBuffer()); - $this->assertStringContainsString('Date', $this->getBuffer()); + $this->assertMatchesRegularExpression( + '/\|\sName[[:space:]]+\|\sServer Path[[:space:]]+\|\sSize[[:space:]]+\|\sDate[[:space:]]+\|/', + $this->getUndecoratedBuffer(), + ); } public function testInfoCacheCannotSeeFoo(): void { - cache()->delete('foo'); + cache()->save('foo', 'bar'); command('cache:info'); + $this->assertStringContainsString('foo', $this->getStreamFilterBuffer()); + + $this->resetStreamFilterBuffer(); - $this->assertStringNotContainsString('foo', $this->getBuffer()); + cache()->delete('foo'); + command('cache:info'); + $this->assertStringNotContainsString('foo', $this->getUndecoratedBuffer()); } } diff --git a/tests/system/Commands/ConfigurableSortImportsTest.php b/tests/system/Commands/ConfigurableSortImportsTest.php index 4f54390d7ffe..e09aa6b8e9ee 100644 --- a/tests/system/Commands/ConfigurableSortImportsTest.php +++ b/tests/system/Commands/ConfigurableSortImportsTest.php @@ -32,7 +32,7 @@ public function testPublishLanguageWithoutOptions(): void $file = APPPATH . 'Language/en/Foobar.php'; $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists($file); - $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Foobar.php'), sha1_file($file)); + $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Legacy/Foobar.php'), sha1_file($file)); if (is_file($file)) { unlink($file); } @@ -45,7 +45,7 @@ public function testEnabledSortImportsWillDisruptLanguageFilePublish(): void $file = APPPATH . 'Language/es/Foobar.php'; $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists($file); - $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Foobar.php'), sha1_file($file)); + $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Legacy/Foobar.php'), sha1_file($file)); if (is_file($file)) { unlink($file); } @@ -62,7 +62,7 @@ public function testDisabledSortImportsWillNotAffectLanguageFilesPublish(): void $file = APPPATH . 'Language/ar/Foobar.php'; $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists($file); - $this->assertSame(sha1_file(SUPPORTPATH . 'Commands/Foobar.php'), sha1_file($file)); + $this->assertSame(sha1_file(SUPPORTPATH . 'Commands/Legacy/Foobar.php'), sha1_file($file)); if (is_file($file)) { unlink($file); } diff --git a/tests/system/Commands/CreateDatabaseTest.php b/tests/system/Commands/CreateDatabaseTest.php index 8bae8282f931..d45ff4efe6b6 100644 --- a/tests/system/Commands/CreateDatabaseTest.php +++ b/tests/system/Commands/CreateDatabaseTest.php @@ -13,10 +13,12 @@ namespace CodeIgniter\Commands; +use CodeIgniter\CLI\CLI; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\OCI8\Connection as OCI8Connection; use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use Config\Database; use PHPUnit\Framework\Attributes\Group; @@ -39,6 +41,7 @@ protected function setUp(): void parent::setUp(); + CLI::resetLastWrite(); $this->dropDatabase(); } @@ -46,6 +49,7 @@ protected function tearDown(): void { parent::tearDown(); + CLI::reset(); $this->dropDatabase(); } @@ -81,9 +85,9 @@ private function closeDatabaseConnections(): void $this->setPrivateProperty(Database::class, 'instances', []); } - protected function getBuffer(): string + private function getUndecoratedBuffer(): string { - return $this->getStreamFilterBuffer(); + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; } public function testCreateDatabase(): void @@ -92,8 +96,17 @@ public function testCreateDatabase(): void $this->markTestSkipped('Needs to run on non-OCI8 drivers.'); } + $name = $this->connection instanceof SQLite3Connection ? 'database.db' : 'database'; + command('db:create database'); - $this->assertStringContainsString('successfully created.', $this->getBuffer()); + $this->assertSame( + <<getUndecoratedBuffer(), + ); } public function testSqliteDatabaseDuplicated(): void @@ -104,9 +117,19 @@ public function testSqliteDatabaseDuplicated(): void command('db:create database'); $this->resetStreamFilterBuffer(); + CLI::resetLastWrite(); + + $database = WRITEPATH . 'database.db'; command('db:create database --ext db'); - $this->assertStringContainsString('already exists.', $this->getBuffer()); + $this->assertSame( + <<getUndecoratedBuffer(), + ); } public function testOtherDriverDuplicatedDatabase(): void @@ -119,6 +142,69 @@ public function testOtherDriverDuplicatedDatabase(): void $this->resetStreamFilterBuffer(); command('db:create database'); - $this->assertStringContainsString('Unable to create the specified database.', $this->getBuffer()); + $this->assertStringContainsString('Unable to create the specified database.', $this->getUndecoratedBuffer()); + } + + public function testOtherDriverCreationFailsWithoutDebug(): void + { + if ($this->connection instanceof SQLite3Connection || $this->connection instanceof OCI8Connection) { + $this->markTestSkipped('Needs to run on non-SQLite3 and non-OCI8 drivers.'); + } + + command('db:create database'); + $this->resetStreamFilterBuffer(); + + $this->connection = Database::connect(); + $this->setPrivateProperty($this->connection, 'DBDebug', false); + + CLI::resetLastWrite(); + command('db:create database'); + $this->assertSame( + <<<'EOT' + + Database creation failed. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testSqlitePromptsForInvalidExtension(): void + { + if (! $this->connection instanceof SQLite3Connection) { + $this->markTestSkipped('Needs to run on SQLite3.'); + } + + $io = new MockInputOutput(); + $io->setInputs(['db']); + CLI::setInputOutput($io); + + command('db:create database --ext txt'); + + $this->assertSame( + <<<'EOT' + Please choose a valid file extension [db, sqlite]: db + Database "database.db" successfully created. + + EOT, + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()) ?? '', + ); + } + + public function testSqliteRejectsInvalidExtensionWhenNonInteractive(): void + { + if (! $this->connection instanceof SQLite3Connection) { + $this->markTestSkipped('Needs to run on SQLite3.'); + } + + command('db:create database --ext txt --no-interaction'); + $this->assertSame( + <<<'EOT' + + Invalid file extension "txt". Use either `db` or `sqlite`. + + EOT, + $this->getUndecoratedBuffer(), + ); } } diff --git a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php index 8b4c433ee535..06bae7c59173 100644 --- a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php +++ b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php @@ -82,11 +82,11 @@ public function testDbTableWithInputs(): void $result, ); $this->assertMatchesRegularExpression( - '/Data of Table "db_migrations"\:/', + '/Data of "db_migrations" table:/', $result, ); $this->assertMatchesRegularExpression( - '/\| id[[:blank:]]+\| version[[:blank:]]+\| class[[:blank:]]+\| group[[:blank:]]+\| namespace[[:blank:]]+\| time[[:blank:]]+\| batch \|/', + '/\| Id[[:blank:]]+\| Version[[:blank:]]+\| Class[[:blank:]]+\| Group[[:blank:]]+\| Namespace[[:blank:]]+\| Time[[:blank:]]+\| Batch \|/', $result, ); } diff --git a/tests/system/Commands/Database/ShowTableInfoTest.php b/tests/system/Commands/Database/ShowTableInfoTest.php index d6f99bd4fc16..7ca76014c33b 100644 --- a/tests/system/Commands/Database/ShowTableInfoTest.php +++ b/tests/system/Commands/Database/ShowTableInfoTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use CodeIgniter\Test\StreamFilterTrait; +use Config\Database; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Database\Seeds\CITestSeeder; @@ -60,10 +61,10 @@ public function testDbTable(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_migrations":'; + $expected = 'Data of "db_migrations" table:'; $this->assertStringContainsString($expected, $result); - $expectedPattern = '/\| id[[:blank:]]+\| version[[:blank:]]+\| class[[:blank:]]+\| group[[:blank:]]+\| namespace[[:blank:]]+\| time[[:blank:]]+\| batch \|/'; + $expectedPattern = '/\| Id[[:blank:]]+\| Version[[:blank:]]+\| Class[[:blank:]]+\| Group[[:blank:]]+\| Namespace[[:blank:]]+\| Time[[:blank:]]+\| Batch \|/'; $this->assertMatchesRegularExpression($expectedPattern, $result); } @@ -83,7 +84,7 @@ public function testDbTableShowsDBConfig(): void $result = $this->getNormalizedResult(); - $expectedPattern = '/\| hostname[[:blank:]]+\| database[[:blank:]]+\| username[[:blank:]]+\| DBDriver[[:blank:]]+\| DBPrefix[[:blank:]]+\| port[[:blank:]]+\|/'; + $expectedPattern = '/\| Hostname[[:blank:]]+\| Database[[:blank:]]+\| Username[[:blank:]]+\| DB Driver[[:blank:]]+\| DB Prefix[[:blank:]]+\| Port[[:blank:]]+\|/'; $this->assertMatchesRegularExpression($expectedPattern, $result); } @@ -98,10 +99,13 @@ public function testDbTableShow(): void $expected = <<<'EOL' +----+---------------------------+-------------+---------------+ - | ID | Table Name | Num of Rows | Num of Fields | + | Id | Table Name | Num of Rows | Num of Fields | +----+---------------------------+-------------+---------------+ EOL; $this->assertStringContainsString($expected, $result); + + // The seeded `db_user` table has 4 rows and 7 fields. + $this->assertMatchesRegularExpression('/\|\s+db_user\s+\|\s+4\s+\|\s+7\s+\|/', $result); } public function testDbTableMetadata(): void @@ -110,12 +114,12 @@ public function testDbTableMetadata(): void $result = $this->getNormalizedResult(); - $expected = 'List of Metadata Information in Table "db_migrations":'; + $expected = 'List of metadata information in "db_migrations" table:'; $this->assertStringContainsString($expected, $result); $result = preg_replace('/\s+/', ' ', $result); $expected = <<<'EOL' - | Field Name | Type | Max Length | Nullable | Default | Primary Key | + | Field Name | Type | Max Length | Nullable? | Default | Primary Key? | EOL; $this->assertStringContainsString($expected, (string) $result); } @@ -126,12 +130,12 @@ public function testDbTableDesc(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+--------------------+--------------------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+--------------------+--------------------+---------+------------+------------+------------+ | 4 | Chris Martin | chris@world.com | UK | | | | | 3 | Richard A Cause... | richard@world.c... | US | | | | @@ -148,12 +152,12 @@ public function testDbTableLimitFieldValueLength(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+----------+----------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+----------+----------+---------+------------+------------+------------+ | 1 | Derek... | derek... | US | | | | | 2 | Ahmad... | ahmad... | Iran | | | | @@ -170,12 +174,12 @@ public function testDbTableLimitRows(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+-------------+--------------------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+-------------+--------------------+---------+------------+------------+------------+ | 1 | Derek Jones | derek@world.com | US | | | | | 2 | Ahmadinejad | ahmadinejad@wor... | Iran | | | | @@ -190,12 +194,12 @@ public function testDbTableAllOptions(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+----------+----------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+----------+----------+---------+------------+------------+------------+ | 4 | Chris... | chris... | UK | | | | | 3 | Richa... | richa... | US | | | | @@ -203,4 +207,71 @@ public function testDbTableAllOptions(): void EOL; $this->assertStringContainsString($expected, $result); } + + public function testDbTableWithInvalidDBGroupSkipsThePrompt(): void + { + command('db:table --dbgroup invalid'); + + $this->assertStringContainsString( + '"invalid" is not a valid database connection group.', + $this->getNormalizedResult(), + ); + } + + public function testDbTableErrorsWhenNoTableSpecifiedAndNonInteractive(): void + { + $exitCode = service('commands')->runCommand('db:table', [], ['no-interaction' => null]); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('No table name was specified.', $this->getNormalizedResult()); + } + + public function testDbTableErrorsWhenTableNotFoundAndNonInteractive(): void + { + $exitCode = service('commands')->runCommand('db:table', ['missing_table'], ['no-interaction' => null]); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('Table "missing_table" was not found in the database.', $this->getNormalizedResult()); + } + + public function testDbTableReportsNoTablesWhenDatabaseIsEmpty(): void + { + // A fresh in-memory SQLite database has no tables, regardless of the + // driver the suite runs against. Route the `default` group to it. + $original = $this->getPrivateProperty(Database::class, 'instances'); + $empty = Database::connect(['DBDriver' => 'SQLite3', 'database' => ':memory:', 'DBPrefix' => '']); + $this->setPrivateProperty(Database::class, 'instances', ['default' => $empty] + $original); + + try { + command('db:table --dbgroup default'); + + $this->assertStringContainsString('Database has no tables!', $this->getNormalizedResult()); + } finally { + $this->setPrivateProperty(Database::class, 'instances', $original); + $empty->close(); + } + } + + public function testDbTableSortsDescWhenTableHasNoIdColumn(): void + { + // `db_team_members` has a composite key and no `id` column, so --desc + // reverses the seeded rows (person_id 33 before 22) instead of adding an + // ORDER BY clause. + command('db:table db_team_members --desc'); + + $result = $this->getNormalizedResult(); + + $expected = 'Data of "db_team_members" table:'; + $this->assertStringContainsString($expected, $result); + + $expected = <<<'EOL' + +---------+-----------+--------+--------+------------+ + | Team_id | Person_id | Role | Status | Created_at | + +---------+-----------+--------+--------+------------+ + | 1 | 33 | mentor | active | | + | 1 | 22 | member | active | | + +---------+-----------+--------+--------+------------+ + EOL; + $this->assertStringContainsString($expected, $result); + } } diff --git a/tests/system/Commands/DatabaseCommandsTest.php b/tests/system/Commands/DatabaseCommandsTest.php index b8894e49f965..577b6abb9604 100644 --- a/tests/system/Commands/DatabaseCommandsTest.php +++ b/tests/system/Commands/DatabaseCommandsTest.php @@ -13,10 +13,17 @@ namespace CodeIgniter\Commands; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Exceptions\ArgumentCountMismatchException; +use CodeIgniter\Database\MigrationRunner; +use CodeIgniter\EnvironmentDetector; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; +use Config\Services; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; /** * @todo To figure out how to transfer this test to `tests/system/Commands/Database/` without breaking DatabaseLive group. @@ -36,85 +43,206 @@ protected function tearDown(): void parent::tearDown(); } - protected function getBuffer(): string - { - return $this->getStreamFilterBuffer(); - } - - protected function clearBuffer(): void - { - $this->resetStreamFilterBuffer(); - } - public function testMigrate(): void { command('migrate --all'); - $this->assertStringContainsString('Migrations complete.', $this->getBuffer()); + $this->assertStringContainsString('Migrations complete.', $this->getStreamFilterBuffer()); command('migrate:rollback'); - $this->clearBuffer(); + $this->resetStreamFilterBuffer(); command('migrate -n Tests\\\\Support'); - $this->assertStringContainsString('Migrations complete.', $this->getBuffer()); + $this->assertStringContainsString('Migrations complete.', $this->getStreamFilterBuffer()); } public function testMigrateRollbackValidBatchNumber(): void { command('migrate --all'); - $this->clearBuffer(); + $this->resetStreamFilterBuffer(); command('migrate:rollback -b 1'); - $this->assertStringContainsString('Done rolling back migrations.', $this->getBuffer()); + $this->assertStringContainsString('Done rolling back migrations.', $this->getStreamFilterBuffer()); } public function testMigrateRollbackInvalidBatchNumber(): void { command('migrate --all'); - $this->clearBuffer(); + $this->resetStreamFilterBuffer(); command('migrate:rollback -b x'); - $this->assertStringContainsString('Invalid batch number: x', $this->getBuffer()); + $this->assertStringContainsString('Invalid batch number: x', $this->getStreamFilterBuffer()); } public function testMigrateRollback(): void { command('migrate --all -g tests'); - $this->clearBuffer(); + $this->resetStreamFilterBuffer(); - command('migrate:rollback -g tests'); - $this->assertStringContainsString('Done rolling back migrations.', $this->getBuffer()); + command('migrate:rollback'); + $this->assertStringContainsString('Done rolling back migrations.', $this->getStreamFilterBuffer()); } public function testMigrateRefresh(): void { command('migrate --all'); - $this->clearBuffer(); + $this->resetStreamFilterBuffer(); command('migrate:refresh'); - $this->assertStringContainsString('Migrations complete.', $this->getBuffer()); + $this->assertStringContainsString('Migrations complete.', $this->getStreamFilterBuffer()); } public function testMigrateStatus(): void { command('migrate --all'); - $this->clearBuffer(); + $this->resetStreamFilterBuffer(); command('migrate:status -g tests'); - $this->assertStringContainsString('Namespace', $this->getBuffer()); - $this->assertStringContainsString('Version', $this->getBuffer()); - $this->assertStringContainsString('Filename', $this->getBuffer()); + $this->assertStringContainsString('Namespace', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Version', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Filename', $this->getStreamFilterBuffer()); + } + + public function testMigrateAbortsWhenMigrationsDisabled(): void + { + config('Migrations')->enabled = false; + Services::resetSingle('migrations'); + + command('migrate'); + $this->assertStringContainsString('Migrations have been loaded but are disabled', $this->getStreamFilterBuffer()); + + config('Migrations')->enabled = true; + Services::resetSingle('migrations'); + } + + public function testMigrateRollbackAbortsWhenMigrationsDisabled(): void + { + config('Migrations')->enabled = false; + Services::resetSingle('migrations'); + + command('migrate:rollback'); + $this->assertStringContainsString('Migrations have been loaded but are disabled', $this->getStreamFilterBuffer()); + + config('Migrations')->enabled = true; + Services::resetSingle('migrations'); + } + + public function testMigrateRollbackAbortsInProductionWithoutForce(): void + { + Services::injectMock('environment', new EnvironmentDetector('production')); + + $exitCode = service('commands')->runCommand('migrate:rollback', [], ['no-interaction' => null]); + $this->assertSame(EXIT_ERROR, $exitCode); + + Services::resetSingle('environment'); + } + + public function testMigrateRefreshAbortsInProductionWithoutForce(): void + { + Services::injectMock('environment', new EnvironmentDetector('production')); + + $exitCode = service('commands')->runCommand('migrate:refresh', [], ['no-interaction' => null]); + $this->assertSame(EXIT_ERROR, $exitCode); + + Services::resetSingle('environment'); + } + + public function testMigrateRefreshForwardsNamespaceGroupAndForceOptions(): void + { + command('migrate --all'); + $this->resetStreamFilterBuffer(); + + command('migrate:refresh -n Tests\\\\Support -g tests -f'); + $this->assertStringContainsString('Migrations complete.', $this->getStreamFilterBuffer()); + } + + public function testMigrateRefreshForwardsAllNamespacesOption(): void + { + command('migrate --all'); + $this->resetStreamFilterBuffer(); + + command('migrate:refresh --all'); + $this->assertStringContainsString('Migrations complete.', $this->getStreamFilterBuffer()); + } + + public function testMigrateReportsGeneralFaultWhenLatestFails(): void + { + $runner = $this->createMock(MigrationRunner::class); + $runner->method('latest')->willReturn(false); + $runner->method('getCliMessages')->willReturn([]); + Services::injectMock('migrations', $runner); + + command('migrate'); + $this->assertStringContainsString(lang('Migrations.generalFault'), $this->getStreamFilterBuffer()); + + Services::resetSingle('migrations'); + } + + public function testMigrateRollbackReportsGeneralFaultWhenRegressFails(): void + { + $runner = $this->createMock(MigrationRunner::class); + $runner->method('getLastBatch')->willReturn(1); + $runner->method('regress')->willReturn(false); + $runner->method('getCliMessages')->willReturn([]); + Services::injectMock('migrations', $runner); + + command('migrate:rollback'); + $this->assertStringContainsString(lang('Migrations.generalFault'), $this->getStreamFilterBuffer()); + + Services::resetSingle('migrations'); + } + + public function testMigrateStatusReportsNoneFoundWhenNoMigrationsExist(): void + { + // A non-testing environment makes the command ignore the Tests\Support namespace. + Services::injectMock('environment', new EnvironmentDetector('production')); + + $runner = $this->createMock(MigrationRunner::class); + $runner->method('findNamespaceMigrations')->willReturn([]); + Services::injectMock('migrations', $runner); + + command('migrate:status'); + $this->assertStringContainsString(lang('Migrations.noneFound'), $this->getStreamFilterBuffer()); + + Services::resetSingle('migrations'); + Services::resetSingle('environment'); } public function testSeed(): void { command('migrate --all'); - $this->clearBuffer(); + $this->resetStreamFilterBuffer(); // use '\\\\' to prevent escaping command('db:seed Tests\\\\Support\\\\Database\\\\Seeds\\\\CITestSeeder'); - $this->assertStringContainsString('Seeded', $this->getBuffer()); - $this->clearBuffer(); + $this->assertStringContainsString('Seeded', $this->getStreamFilterBuffer()); + $this->resetStreamFilterBuffer(); command('db:seed Foobar.php'); - $this->assertStringContainsString('The specified seeder is not a valid file:', $this->getBuffer()); + $this->assertStringContainsString('The specified seeder is not a valid file:', $this->getStreamFilterBuffer()); + } + + public function testSeedPromptsForSeederNameWhenMissing(): void + { + command('migrate --all'); + $this->resetStreamFilterBuffer(); + + $io = new MockInputOutput(); + $io->setInputs([CITestSeeder::class]); + CLI::setInputOutput($io); + + command('db:seed'); + + CLI::resetInputOutput(); + + $output = $io->getOutput(); + $this->assertStringContainsString(lang('Migrations.migSeeder'), $output); + $this->assertStringContainsString('Seeded', $output); + } + + public function testSeedAbortsWhenSeederNameMissingAndNonInteractive(): void + { + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage('Command "db:seed" is missing the following required argument: seeder_name.'); + + command('db:seed --no-interaction'); } } diff --git a/tests/system/Commands/Encryption/GenerateKeyTest.php b/tests/system/Commands/Encryption/GenerateKeyTest.php index 387bdf7a1641..c36d17c05b29 100644 --- a/tests/system/Commands/Encryption/GenerateKeyTest.php +++ b/tests/system/Commands/Encryption/GenerateKeyTest.php @@ -13,13 +13,15 @@ namespace CodeIgniter\Commands\Encryption; +use CodeIgniter\CLI\CLI; use CodeIgniter\Config\Services; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; -use CodeIgniter\Test\Filters\CITestStreamFilter; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\WithoutErrorHandler; @@ -39,6 +41,7 @@ protected function setUp(): void { parent::setUp(); + CLI::resetLastWrite(); Services::injectMock('superglobals', new Superglobals()); $this->envPath = ROOTPATH . '.env'; @@ -62,14 +65,9 @@ protected function tearDown(): void } $this->resetEnvironment(); - } - /** - * Gets buffer contents then releases it. - */ - protected function getBuffer(): string - { - return $this->getStreamFilterBuffer(); + CLI::resetLastWrite(); + CLI::reset(); } protected function resetEnvironment(): void @@ -82,13 +80,15 @@ protected function resetEnvironment(): void public function testGenerateKeyShowsEncodedKey(): void { command('key:generate --show'); - $this->assertStringContainsString('hex2bin:', $this->getBuffer()); + $this->assertStringContainsString('hex2bin:', $this->getStreamFilterBuffer()); + $this->resetStreamFilterBuffer(); command('key:generate --prefix base64 --show'); - $this->assertStringContainsString('base64:', $this->getBuffer()); + $this->assertStringContainsString('base64:', $this->getStreamFilterBuffer()); + $this->resetStreamFilterBuffer(); command('key:generate --prefix hex2bin --show'); - $this->assertStringContainsString('hex2bin:', $this->getBuffer()); + $this->assertStringContainsString('hex2bin:', $this->getStreamFilterBuffer()); } #[PreserveGlobalState(false)] @@ -96,17 +96,19 @@ public function testGenerateKeyShowsEncodedKey(): void public function testGenerateKeyCreatesNewKey(): void { command('key:generate'); - $this->assertStringContainsString('successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath)); $this->assertStringContainsString('hex2bin:', (string) file_get_contents($this->envPath)); + $this->resetStreamFilterBuffer(); command('key:generate --prefix base64 --force'); - $this->assertStringContainsString('successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath)); $this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath)); + $this->resetStreamFilterBuffer(); command('key:generate --prefix hex2bin --force'); - $this->assertStringContainsString('successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath)); $this->assertStringContainsString('hex2bin:', (string) file_get_contents($this->envPath)); } @@ -117,8 +119,9 @@ public function testDefaultShippedEnvIsMissing(): void command('key:generate'); rename(ROOTPATH . 'lostenv', ROOTPATH . 'env'); - $this->assertStringContainsString('Both default shipped', $this->getBuffer()); - $this->assertStringContainsString('Error in setting', $this->getBuffer()); + $this->assertStringContainsString('Both default shipped', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Here\'s your new key instead:', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('Failed to write', $this->getStreamFilterBuffer()); } /** @@ -130,7 +133,7 @@ public function testKeyGenerateWhenKeyIsMissingInDotEnvFile(): void command('key:generate'); - $this->assertStringContainsString('Application\'s new encryption key was successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertSame("\nencryption.key = " . env('encryption.key'), file_get_contents($this->envPath)); } @@ -146,9 +149,9 @@ public function testKeyGenerateWhenNewHexKeyIsSubsequentlyCommentedOut(): void )); $this->assertSame(1, $count, 'Failed commenting out the previously set application key.'); - CITestStreamFilter::$buffer = ''; + $this->resetStreamFilterBuffer(); command('key:generate --force'); - $this->assertStringContainsString('was successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.'); } @@ -164,12 +167,34 @@ public function testKeyGenerateWhenNewBase64KeyIsSubsequentlyCommentedOut(): voi )); $this->assertSame(1, $count, 'Failed commenting out the previously set application key.'); - CITestStreamFilter::$buffer = ''; + $this->resetStreamFilterBuffer(); command('key:generate --force'); - $this->assertStringContainsString('was successfully set.', $this->getBuffer()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); $this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.'); } + /** + * Simulates a stale env cache: the `.env` file has a valid key, but + * `env('encryption.key')` resolves to '' because nothing has loaded it + * into the superglobals. The primary regex (built from `oldKey`) cannot + * locate the line, so the fallback regex must replace the existing entry. + */ + public function testKeyGenerateReplacesUnloadedKeyInDotEnvFile(): void + { + $existingKey = 'hex2bin:' . str_repeat('a', 64); + file_put_contents($this->envPath, "encryption.key = {$existingKey}\n"); + + $this->assertSame('', env('encryption.key', '')); + + command('key:generate --force'); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); + + $contents = @file_get_contents($this->envPath); + $this->assertIsString($contents, 'Failed to read .env file contents.'); + $this->assertStringNotContainsString($existingKey, $contents); + $this->assertStringContainsString('encryption.key = ' . env('encryption.key'), $contents); + } + public function testKeyGenerateReplacesExportPrefixedEncryptionKey(): void { $existingKey = 'hex2bin:' . str_repeat('a', 64); @@ -205,4 +230,95 @@ public function testKeyGenerateNotFooledByCommentMentioningEncryptionKey(): void 'A real `encryption.key` setting must be appended even when a comment mentions the name.', ); } + + public function testKeyGenerateCancelsWhenOverwritePromptIsDeclined(): void + { + command('key:generate'); + $key = env('encryption.key', ''); + $this->assertNotSame('', $key); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + $this->resetStreamFilterBuffer(); + command('key:generate'); + + $this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.'); + $this->assertStringContainsString($key, (string) file_get_contents($this->envPath)); + $this->assertStringContainsString('Overwrite existing key?', $io->getOutput()); + $this->assertStringContainsString('Setting new encryption key cancelled.', $io->getOutput()); + } + + public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void + { + command('key:generate'); + $oldKey = env('encryption.key', ''); + $this->assertNotSame('', $oldKey); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + $this->resetStreamFilterBuffer(); + command('key:generate --prefix base64'); + + $this->assertNotSame($oldKey, env('encryption.key', $oldKey)); + $this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath)); + $this->assertStringContainsString('Overwrite existing key?', $io->getOutput()); + $this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $io->getOutput()); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testKeyGenerateAbortsNonInteractivelyWithExistingKey(): void + { + command('key:generate'); + $key = env('encryption.key', ''); + $this->assertNotSame('', $key); + + $this->resetStreamFilterBuffer(); + command('key:generate --no-interaction'); + + $this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.'); + $this->assertStringContainsString( + 'Setting new encryption key aborted: pass --force to overwrite the existing key in non-interactive mode.', + $this->getStreamFilterBuffer(), + ); + } + + public function testKeyGenerateErrorsOnInvalidPrefixNonInteractively(): void + { + command('key:generate --prefix invalid --show --no-interaction'); + + $this->assertStringContainsString('Invalid prefix "invalid"', $this->getStreamFilterBuffer()); + } + + public function testKeyGeneratePromptsForInvalidPrefix(): void + { + $io = new MockInputOutput(); + $io->setInputs(['hex2bin']); + CLI::setInputOutput($io); + + command('key:generate --prefix invalid --show'); + + $this->assertStringContainsString('Please provide a valid prefix to use.', $io->getOutput()); + $this->assertStringContainsString('hex2bin:', $io->getOutput()); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testKeyGenerateErrorsWhenEnvFileIsNotWritable(): void + { + command('key:generate'); + chmod($this->envPath, 0o444); + + try { + $this->resetStreamFilterBuffer(); + command('key:generate --force'); + + $this->assertStringContainsString(sprintf('Failed to write new encryption key to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer()); + } finally { + chmod($this->envPath, 0o644); + } + } } diff --git a/tests/system/Commands/Encryption/RotateKeyTest.php b/tests/system/Commands/Encryption/RotateKeyTest.php new file mode 100644 index 000000000000..2dda2fabcda5 --- /dev/null +++ b/tests/system/Commands/Encryption/RotateKeyTest.php @@ -0,0 +1,590 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Encryption; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Config\DotEnv; +use CodeIgniter\Config\Services; +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; + +/** + * @internal + */ +#[CoversClass(RotateKey::class)] +#[Group('Others')] +final class RotateKeyTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private const SEED_KEY = 'hex2bin:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + private string $envPath; + private string $backupEnvPath; + + #[WithoutErrorHandler] + protected function setUp(): void + { + parent::setUp(); + + CLI::resetLastWrite(); + Services::injectMock('superglobals', new Superglobals()); + + $this->envPath = ROOTPATH . '.env'; + $this->backupEnvPath = ROOTPATH . '.env.backup'; + + if (is_file($this->envPath)) { + rename($this->envPath, $this->backupEnvPath); + } + + $this->resetEnvironment(); + } + + protected function tearDown(): void + { + if (is_file($this->envPath)) { + unlink($this->envPath); + } + + if (is_file($this->backupEnvPath)) { + rename($this->backupEnvPath, $this->envPath); + } + + $this->resetEnvironment(); + $this->resetServices(); + + CLI::reset(); + } + + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + private static function getUndecoratedIoOutput(MockInputOutput $io): string + { + return preg_replace('/\e\[[^m]+m/', '', $io->getOutput()) ?? ''; + } + + private function resetEnvironment(): void + { + putenv('encryption.key'); + putenv('encryption.previousKeys'); + unset($_ENV['encryption.key'], $_ENV['encryption.previousKeys']); + + $superglobals = service('superglobals'); + $superglobals->unsetServer('encryption.key'); + $superglobals->unsetServer('encryption.previousKeys'); + } + + private function seedEnv(string $key, string $previousKeys = ''): void + { + $content = "encryption.key = {$key}\n"; + + if ($previousKeys !== '') { + $content .= "encryption.previousKeys = {$previousKeys}\n"; + } + + file_put_contents($this->envPath, $content); + + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + } + + public function testRotateMovesCurrentKeyToPreviousKeysAndGeneratesNew(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + + Encryption key rotated. 1 previous key retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + $this->getUndecoratedBuffer(), + ); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^encryption\.key = hex2bin:[a-f0-9]{64}\nencryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + 'previousKeys should be inserted on the line directly after encryption.key.', + ); + $this->assertNotSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotatePrependsToExistingPreviousKeysList(): void + { + $older = 'hex2bin:' . str_repeat('a', 64); + $oldest = 'hex2bin:' . str_repeat('b', 64); + $this->seedEnv(self::SEED_KEY, "{$older},{$oldest}"); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + + Encryption key rotated. 3 previous keys retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + $this->getUndecoratedBuffer(), + ); + + $this->assertSame( + self::SEED_KEY . ",{$older},{$oldest}", + env('encryption.previousKeys'), + ); + } + + public function testRotateDeduplicatesWhenCurrentKeyAlreadyInPreviousKeys(): void + { + $other = 'hex2bin:' . str_repeat('a', 64); + $this->seedEnv(self::SEED_KEY, self::SEED_KEY . ",{$other}"); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertSame( + self::SEED_KEY . ",{$other}", + env('encryption.previousKeys'), + 'Current key should not appear twice in the rotated list.', + ); + $this->assertSame( + 1, + substr_count($contents, 'encryption.previousKeys = '), + 'Should rewrite the previousKeys line in place rather than appending a duplicate.', + ); + $this->assertStringNotContainsString( + "\n\nencryption.previousKeys", + $contents, + 'In-place replacement should not introduce a blank line before encryption.previousKeys.', + ); + } + + public function testRotateRespectsKeepLimit(): void + { + $a = 'hex2bin:' . str_repeat('a', 64); + $b = 'hex2bin:' . str_repeat('b', 64); + $c = 'hex2bin:' . str_repeat('c', 64); + $this->seedEnv(self::SEED_KEY, "{$a},{$b},{$c}"); + + command('key:rotate --force --keep=2'); + + $this->assertSame( + self::SEED_KEY . ",{$a}", + env('encryption.previousKeys'), + ); + $contents = (string) file_get_contents($this->envPath); + $this->assertStringNotContainsString($b, $contents); + $this->assertStringNotContainsString($c, $contents); + } + + public function testRotateRespectsKeepLimitOfOne(): void + { + $older = 'hex2bin:' . str_repeat('a', 64); + $oldest = 'hex2bin:' . str_repeat('b', 64); + $this->seedEnv(self::SEED_KEY, "{$older},{$oldest}"); + + command('key:rotate --force --keep=1'); + + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + $contents = (string) file_get_contents($this->envPath); + $this->assertStringNotContainsString($older, $contents); + $this->assertStringNotContainsString($oldest, $contents); + } + + public function testRotateErrorsWhenNoCurrentKey(): void + { + file_put_contents($this->envPath, "# encryption.key =\n"); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + + No existing `encryption.key` to rotate. Run `spark key:generate` first. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertStringNotContainsString('encryption.previousKeys', (string) file_get_contents($this->envPath)); + } + + public function testRotateCancelsWhenOverwritePromptIsDeclined(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + command('key:rotate'); + + $this->assertSame( + <<<'EOT' + Rotate encryption key? The current key will be moved to `previousKeys`. [n, y]: n + Key rotation cancelled. + + EOT, + self::getUndecoratedIoOutput($io), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertStringContainsString(self::SEED_KEY, (string) file_get_contents($this->envPath)); + } + + public function testRotateOverwritesWhenOverwritePromptIsConfirmed(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + command('key:rotate --prefix base64'); + + $this->assertSame( + <<<'EOT' + Rotate encryption key? The current key will be moved to `previousKeys`. [n, y]: y + Encryption key rotated. 1 previous key retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + self::getUndecoratedIoOutput($io), + ); + $this->assertNotSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateAbortsNonInteractively(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --no-interaction'); + + $this->assertSame( + <<<'EOT' + + Key rotation aborted: pass --force to rotate the encryption key in non-interactive mode. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateWithBase64Prefix(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --prefix base64 --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression('/^encryption\.key = base64:[A-Za-z0-9+\/]+={0,2}$/m', $contents); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateErrorsOnInvalidPrefixNonInteractively(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --prefix invalid --no-interaction'); + + $this->assertSame( + <<<'EOT' + + Invalid prefix "invalid". Use either "hex2bin" or "base64". + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateInteractRePromptsForInvalidPrefix(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + // First input answers the invalid-prefix recovery prompt; second answers the rotate confirmation. + $io->setInputs(['base64', 'y']); + CLI::setInputOutput($io); + + command('key:rotate --prefix invalid'); + + $output = self::getUndecoratedIoOutput($io); + $this->assertStringContainsString('Please provide a valid prefix to use. [hex2bin, base64]: base64', $output); + $this->assertStringContainsString('Encryption key rotated. 1 previous key retained for decryption fallback.', $output); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression('/^encryption\.key = base64:[A-Za-z0-9+\/]+={0,2}$/m', $contents); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateInteractSkipsConfirmationWhenNoCurrentKey(): void + { + file_put_contents($this->envPath, "# encryption.key =\n"); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + // No MockInputOutput inputs are set; if interact() reached the rotate prompt it would + // throw `LogicException('No input data...')` from `MockInputOutput::input()`. + $io = new MockInputOutput(); + CLI::setInputOutput($io); + + command('key:rotate'); + + $this->assertSame( + <<<'EOT' + + No existing `encryption.key` to rotate. Run `spark key:generate` first. + + EOT, + self::getUndecoratedIoOutput($io), + ); + } + + public function testRotateRejectsNegativeKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=-1'); + + $this->assertSame( + <<<'EOT' + + The --keep option must be a non-negative integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateRejectsNonNumericKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=abc'); + + $this->assertSame( + <<<'EOT' + + The --keep option must be a non-negative integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateRejectsFractionalKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=3.5'); + + $this->assertSame( + <<<'EOT' + + The --keep option must be a non-negative integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateRejectsNegativeLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=-1'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame( + $envContentsBefore, + (string) file_get_contents($this->envPath), + 'Validation must reject the run before any .env mutation.', + ); + } + + public function testRotateRejectsZeroLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=0'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateRejectsNonNumericLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=abc'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateRejectsFractionalLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=3.5'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateErrorsWhenEnvFileIsMissing(): void + { + // No seedEnv() call: `.env` is absent. Populate the env var directly so + // the up-front `encryption.key` existence check passes. + putenv('encryption.key=' . self::SEED_KEY); + $_ENV['encryption.key'] = self::SEED_KEY; + + command('key:rotate --force'); + + $this->assertStringContainsString('Cannot rotate: `.env` file not found at', $this->getUndecoratedBuffer()); + $this->assertFileDoesNotExist($this->envPath, 'No `.env` file should have been created.'); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testRotateErrorsWhenEnvFileIsNotWritable(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + chmod($this->envPath, 0o444); + + try { + command('key:rotate --force'); + + $output = $this->getUndecoratedBuffer(); + $this->assertStringContainsString('Cannot rotate: `.env` file at', $output); + $this->assertStringContainsString('is not writable', $output); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } finally { + chmod($this->envPath, 0o644); + } + } + + public function testRotateIgnoresCommentMentioningPreviousKeysWhenInserting(): void + { + $envContents = "# encryption.previousKeys is for decryption fallback\nencryption.key = " . self::SEED_KEY . "\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^encryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + 'A real `encryption.previousKeys` setting must be written even when a comment mentions the name.', + ); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateReplacesPreviousKeysLineWithExportPrefix(): void + { + $existing = 'hex2bin:' . str_repeat('a', 64); + $envContents = 'encryption.key = ' . self::SEED_KEY . "\nexport encryption.previousKeys = {$existing}\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^export encryption\.previousKeys = ' . preg_quote(self::SEED_KEY . ',' . $existing, '/') . '$/m', + $contents, + 'The existing `export` prefix should be preserved and the value rewritten.', + ); + $this->assertSame( + self::SEED_KEY . ',' . $existing, + env('encryption.previousKeys'), + ); + } + + public function testRotateInsertsAfterExportPrefixedEncryptionKey(): void + { + $envContents = 'export encryption.key = ' . self::SEED_KEY . "\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^export encryption\.key = hex2bin:[a-f0-9]{64}\nencryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + '`encryption.previousKeys` should be inserted on the line directly after an `export`-prefixed `encryption.key`.', + ); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } +} diff --git a/tests/system/Commands/Generators/FormRequestGeneratorTest.php b/tests/system/Commands/Generators/FormRequestGeneratorTest.php new file mode 100644 index 000000000000..c2acc73ec4ca --- /dev/null +++ b/tests/system/Commands/Generators/FormRequestGeneratorTest.php @@ -0,0 +1,58 @@ + + * + * 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\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class FormRequestGeneratorTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + protected function tearDown(): void + { + parent::tearDown(); + + $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); + } + } + + public function testGenerateFormRequest(): void + { + command('make:request user'); + + $file = APPPATH . 'Requests/User.php'; + + $this->assertFileExists($file); + $this->assertStringContainsString( + 'Defaults to true in FormRequest. Override only when authorization', + (string) file_get_contents($file), + ); + } + + public function testGenerateFormRequestWithOptionSuffix(): void + { + command('make:request admin -suffix'); + $this->assertFileExists(APPPATH . 'Requests/AdminRequest.php'); + } +} diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index 519f5ab33f65..07c50913a897 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -13,8 +13,12 @@ namespace CodeIgniter\Commands; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CodeIgniter; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\Group; /** @@ -25,42 +29,344 @@ final class HelpCommandTest extends CIUnitTestCase { use StreamFilterTrait; - protected function getBuffer(): string + #[After] + #[Before] + protected function resetCli(): void { - return $this->getStreamFilterBuffer(); + CLI::reset(); } - public function testHelpCommand(): void + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + public function testNoArgumentDescribesItself(): void { command('help'); - // make sure the result looks like a command list - $this->assertStringContainsString('Displays basic usage information.', $this->getBuffer()); - $this->assertStringContainsString('command_name', $this->getBuffer()); + $this->assertSame( + <<<'EOT' + + Usage: + help [options] [--] [] + + Description: + Displays basic usage information. + + Arguments: + command_name The command name. [default: "help"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); } - public function testHelpCommandWithMissingUsage(): void + public function testDescribeCommandNoArguments(): void { - command('help app:info'); - $this->assertStringContainsString('app:info [arguments]', $this->getBuffer()); + command('help app:about'); + + $this->assertSame( + <<<'EOT' + + Usage: + app:about [options] [--] [] [...] + app:about required-value + + Description: + Displays basic application information. + + Arguments: + required Unused required argument. + optional Unused optional argument. [default: "val"] + array Unused array argument. [default: ["a", "b"]] + + Options: + -f, --foo=FOO Option that requires a value. [default: "qux"] + -a, --bar[=BAR] Option that optionally accepts a value. + -b, --baz=BAZ Option that allows multiple values. [default: ["a"]] (multiple values allowed) + --quux|--no-quux Negatable option. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); } - public function testHelpCommandOnSpecificCommand(): void + public function testDescribeSpecificCommand(): void { command('help cache:clear'); - $this->assertStringContainsString('Clears the current system caches.', $this->getBuffer()); + + $this->assertSame( + <<<'EOT' + + Usage: + cache:clear [options] [--] [] + + Description: + Clears the current system caches. + + Arguments: + driver The cache driver to use. [default: "file"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testEmptyStringOptionDefaultIsNotDisplayed(): void + { + // `migrate:status` declares `--group` with an empty-string default, + // which must be suppressed the same way a null default is. + command('help migrate:status'); + + $this->assertSame( + <<<'EOT' + + Usage: + migrate:status [options] + + Description: + Displays a list of all migrations and whether they've been run or not. + + Options: + -g, --group=GROUP Set database group. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeCommandWithAliases(): void + { + command('help fixture:aliased'); + + $this->assertSame( + <<<'EOT' + + Usage: + fixture:aliased [options] + + Description: + Fixture command exercising command aliases. + + Aliases: + fixture:alias + fa + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeCommandViaAliasResolvesToCanonical(): void + { + command('help fixture:alias'); + + $this->assertSame( + <<<'EOT' + + Usage: + fixture:aliased [options] + + Description: + Fixture command exercising command aliases. + + Aliases: + fixture:alias + fa + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); } - public function testHelpCommandOnInexistentCommand(): void + public function testDescribeUnavailableCommand(): void + { + command('help test:unavailable'); + + $this->assertSame( + <<<'EOT' + + Usage: + test:unavailable [options] + + Description: + Fixture command to test runtime availability checks. + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeLegacyCommandUsesLegacyShowHelp(): void + { + // `app:info` is a legacy BaseCommand fixture. Help must take the + // legacy branch and delegate to BaseCommand::showHelp() instead of + // rendering via the modern describeHelp() pipeline. + command('help app:info'); + + $this->assertSame( + <<<'EOT' + + Usage: + app:info [arguments] + + Description: + Displays basic application information. + + Arguments: + draft unused + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeInexistentCommand(): void { command('help fixme'); - $this->assertStringContainsString('Command "fixme" not found', $this->getBuffer()); + + $this->assertSame("\nCommand \"fixme\" not found.\n", $this->getUndecoratedBuffer()); } - public function testHelpCommandOnInexistentCommandButWithAlternatives(): void + public function testDescribeInexistentCommandButWithAlternatives(): void { command('help clear'); - $this->assertStringContainsString('Command "clear" not found.', $this->getBuffer()); - $this->assertStringContainsString('Did you mean one of these?', $this->getBuffer()); + + $this->assertSame( + <<<'EOT' + + Command "clear" not found. + + Did you mean one of these? + cache:clear + debugbar:clear + logs:clear + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeInexistentCommandSuggestsLegacyAlternatives(): void + { + command('help app:inf'); + + $this->assertSame( + <<<'EOT' + + Command "app:inf" not found. + + Did you mean this? + app:info + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeUsingHelpOption(): void + { + command('cache:clear --help'); + + $this->assertSame( + <<<'EOT' + + Usage: + cache:clear [options] [--] [] + + Description: + Clears the current system caches. + + Arguments: + driver The cache driver to use. [default: "file"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeUsingHelpShortOption(): void + { + command('cache:clear -h'); + + $this->assertSame( + <<<'EOT' + + Usage: + cache:clear [options] [--] [] + + Description: + Clears the current system caches. + + Arguments: + driver The cache driver to use. [default: "file"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testNormalHelpCommandHasNoBanner(): void + { + command('help'); + + $this->assertStringNotContainsString( + sprintf('CodeIgniter %s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); + $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); + } + + public function testHelpCommandWithDoubleHyphenStillRemovesBanner(): void + { + command('help -- list'); + + $this->assertStringNotContainsString( + sprintf('CodeIgniter %s Command Line Tool', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); + $this->assertStringContainsString('Lists the available commands.', $this->getStreamFilterBuffer()); } } diff --git a/tests/system/Commands/Housekeeping/ClearDebugbarTest.php b/tests/system/Commands/Housekeeping/ClearDebugbarTest.php index e380e0aaaf68..19be1f23fb81 100644 --- a/tests/system/Commands/Housekeeping/ClearDebugbarTest.php +++ b/tests/system/Commands/Housekeeping/ClearDebugbarTest.php @@ -75,7 +75,7 @@ public function testClearDebugbarWorks(): void $this->assertFileDoesNotExist(WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . "debugbar_{$this->time}.json"); $this->assertFileExists(WRITEPATH . 'debugbar' . DIRECTORY_SEPARATOR . 'index.html'); $this->assertSame( - "\nDebugbar cleared.\n", + sprintf("\nCleared debugbar JSON files in \"%s\".\n", clean_path(WRITEPATH . 'debugbar')), preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), ); } @@ -95,7 +95,7 @@ public function testClearDebugbarWithError(): void $this->assertFileExists($path); $this->assertSame( - "\nError deleting the debugbar JSON files.\n", + sprintf("\nError deleting the debugbar JSON files in \"%s\".\n", clean_path(WRITEPATH . 'debugbar')), preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), ); } diff --git a/tests/system/Commands/Housekeeping/ClearLogsTest.php b/tests/system/Commands/Housekeeping/ClearLogsTest.php index a8469873e84d..79f6cd7a42ff 100644 --- a/tests/system/Commands/Housekeeping/ClearLogsTest.php +++ b/tests/system/Commands/Housekeeping/ClearLogsTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; @@ -80,10 +81,13 @@ public function testClearLogsUsingForce(): void $this->assertFileDoesNotExist(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . 'index.html'); - $this->assertSame("\nLogs cleared.\n", preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer())); + $this->assertSame( + sprintf("\nLog files in %s cleared.\n", clean_path(WRITEPATH . 'logs')), + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + ); } - public function testClearLogsAbortsClearWithoutForce(): void + public function testClearLogsCancelsWithoutForce(): void { $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); @@ -95,16 +99,19 @@ public function testClearLogsAbortsClearWithoutForce(): void $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); $this->assertSame( - <<<'EOT' - Are you sure you want to delete the logs? [n, y]: n - Deleting logs aborted. - - EOT, + sprintf( + <<<'EOT' + Delete all log files in %s? [n, y]: n + Log deletion cancelled. + + EOT, + clean_path(WRITEPATH . 'logs'), + ), preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), ); } - public function testClearLogsAbortsClearWithoutForceWithDefaultAnswer(): void + public function testClearLogsCancelsWithoutForceWithDefaultAnswer(): void { $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); @@ -118,15 +125,46 @@ public function testClearLogsAbortsClearWithoutForceWithDefaultAnswer(): void $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); $this->assertSame( - <<getOutput()), + ); + } + + #[DataProvider('provideClearLogsAbortsNonInteractively')] + public function testClearLogsAbortsNonInteractively(string $flag): void + { + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + + command("logs:clear {$flag}"); + + $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); + $this->assertSame( + <<<'EOT' + + Log deletion aborted: pass --force to delete log files in non-interactive mode. EOT, - preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), ); } + /** + * @return iterable + */ + public static function provideClearLogsAbortsNonInteractively(): iterable + { + yield 'long form' => ['--no-interaction']; + + yield 'short form' => ['-N']; + } + public function testClearLogsWithoutForceButWithConfirmation(): void { $this->assertFileExists(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); @@ -139,11 +177,14 @@ public function testClearLogsWithoutForceButWithConfirmation(): void $this->assertFileDoesNotExist(WRITEPATH . 'logs' . DIRECTORY_SEPARATOR . "log-{$this->date}.log"); $this->assertSame( - <<<'EOT' - Are you sure you want to delete the logs? [n, y]: y - Logs cleared. - - EOT, + sprintf( + <<<'EOT' + Delete all log files in %1$s? [n, y]: y + Log files in %1$s cleared. + + EOT, + clean_path(WRITEPATH . 'logs'), + ), preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), ); } @@ -164,7 +205,7 @@ public function testClearLogsFailsOnChmodFailure(): void $this->assertFileExists($path); $this->assertSame( - "\nError in deleting the logs files.\n", + sprintf("\nFailed to delete log files in %s.\n", clean_path(WRITEPATH . 'logs')), preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), ); } diff --git a/tests/system/Commands/ListCommandsTest.php b/tests/system/Commands/ListCommandsTest.php index 9d86b1e0dd39..9683aacc2d64 100644 --- a/tests/system/Commands/ListCommandsTest.php +++ b/tests/system/Commands/ListCommandsTest.php @@ -13,13 +13,19 @@ namespace CodeIgniter\Commands; +use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Commands; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use Config\Services; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; +use Tests\Support\Duplicates\DuplicateLegacy; +use Tests\Support\Duplicates\DuplicateModern; /** * @internal @@ -32,11 +38,18 @@ final class ListCommandsTest extends CIUnitTestCase #[After] #[Before] - protected function resetCli(): void + protected function resetAll(): void { + $this->resetServices(); + CLI::reset(); } + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + public function testRunCommand(): void { command('list'); @@ -52,4 +65,146 @@ public function testRunCommandWithSimpleOption(): void $this->assertStringContainsString('cache:clear', $this->getStreamFilterBuffer()); $this->assertStringNotContainsString('Clears the current system caches.', $this->getStreamFilterBuffer()); } + + public function testUnavailableCommandIsStillListed(): void + { + command('list'); + + $this->assertStringContainsString('test:unavailable', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Fixture command to test runtime availability checks.', $this->getStreamFilterBuffer()); + } + + public function testAliasIsListedAsItsOwnRowInDetailedOutput(): void + { + command('list'); + + $buffer = $this->getUndecoratedBuffer(); + + // The canonical command keeps its description on its own row... + $this->assertMatchesRegularExpression( + '/\n {2}fixture:aliased\s+Fixture command exercising command aliases\.\n/', + $buffer, + ); + // ...and each alias renders as a separate row pointing back to it. + $this->assertMatchesRegularExpression( + '/\n {2}fixture:alias\s+\[alias of fixture:aliased\]\n/', + $buffer, + ); + $this->assertMatchesRegularExpression( + '/\n {2}fa\s+\[alias of fixture:aliased\]\n/', + $buffer, + ); + } + + public function testAliasIsListedInSimpleOutput(): void + { + command('list --simple'); + + $buffer = $this->getUndecoratedBuffer(); + + // The canonical command and each alias are emitted as their own lines. + $this->assertStringContainsString("fixture:aliased\n", $buffer); + $this->assertStringContainsString("fixture:alias\n", $buffer); + $this->assertStringContainsString("fa\n", $buffer); + } + + public function testDuplicateCommandNameListedOnceInSimpleOutput(): void + { + $list = new ListCommands($this->mockRunnerWithDuplicate()); + + $list->run([], ['simple' => null]); + + $this->assertSame(1, substr_count($this->getStreamFilterBuffer(), "dup:test\n")); + } + + public function testDuplicateCommandNameShowsLegacyDescriptionInDetailedOutput(): void + { + $list = new ListCommands($this->mockRunnerWithDuplicate()); + + $list->run([], []); + + $buffer = $this->getStreamFilterBuffer(); + + $this->assertStringContainsString('Legacy dup description', $buffer); + $this->assertStringNotContainsString('Modern dup description', $buffer); + } + + public function testShadowedAliasIsNotListedInDetailedOutput(): void + { + $list = new ListCommands($this->discoveredRunnerWithDuplicate()); + $this->resetStreamFilterBuffer(); + + $list->run([], []); + + $buffer = $this->getUndecoratedBuffer(); + + $this->assertStringContainsString('dup:test', $buffer); + $this->assertStringNotContainsString('dup:alias', $buffer); + } + + public function testShadowedAliasIsNotListedInSimpleOutput(): void + { + $list = new ListCommands($this->discoveredRunnerWithDuplicate()); + $this->resetStreamFilterBuffer(); + + $list->run([], ['simple' => null]); + + $buffer = $this->getUndecoratedBuffer(); + + $this->assertStringContainsString('dup:test', $buffer); + $this->assertStringNotContainsString('dup:alias', $buffer); + } + + /** + * Runs real discovery against the colliding legacy/modern `dup:test` + * fixtures so the alias suppression in `Commands::registerAliases()` is + * exercised end to end, not stubbed. + */ + private function discoveredRunnerWithDuplicate(): Commands + { + $legacyFile = (new ReflectionClass(DuplicateLegacy::class))->getFileName(); + $modernFile = (new ReflectionClass(DuplicateModern::class))->getFileName(); + + $locator = $this->getMockBuilder(FileLocator::class) + ->setConstructorArgs([service('autoloader')]) + ->onlyMethods(['listFiles', 'findQualifiedNameFromPath']) + ->getMock(); + $locator->method('listFiles')->with('Commands/')->willReturn([$legacyFile, $modernFile]); + $locator->expects($this->exactly(2))->method('findQualifiedNameFromPath')->willReturnMap([ + [$legacyFile, DuplicateLegacy::class], + [$modernFile, DuplicateModern::class], + ]); + Services::injectMock('locator', $locator); + + return new Commands(); + } + + private function mockRunnerWithDuplicate(): Commands + { + $runner = $this->createMock(Commands::class); + $runner->expects($this->once()) + ->method('getCommands') + ->willReturn([ + 'dup:test' => [ + 'class' => DuplicateLegacy::class, + 'file' => 'irrelevant', + 'group' => 'Duplicates', + 'description' => 'Legacy dup description', + ], + ]); + $runner->expects($this->once()) + ->method('getModernCommands') + ->willReturn([ + 'dup:test' => [ + 'class' => DuplicateModern::class, + 'file' => 'irrelevant', + 'group' => 'Duplicates', + 'description' => 'Modern dup description', + 'aliases' => [], + ], + ]); + $runner->method('getCommandAliases')->willReturn([]); + + return $runner; + } } diff --git a/tests/system/Commands/Server/ServeTest.php b/tests/system/Commands/Server/ServeTest.php index 2771d2b64bd0..10ca6aab62d8 100644 --- a/tests/system/Commands/Server/ServeTest.php +++ b/tests/system/Commands/Server/ServeTest.php @@ -33,7 +33,7 @@ private function buildServeCommand(): Closure { /** @var Closure(string, string, int, string, string): string */ return self::getPrivateMethodInvoker( - new Serve(service('logger'), service('commands')), + new Serve(service('commands')), 'buildServeCommand', ); } diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php index bfbd00d8bf83..30670336f6e0 100644 --- a/tests/system/Commands/Translation/LocalizationFinderTest.php +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -87,7 +87,7 @@ public function testUpdateWithIncorrectLocaleOption(): void self::$locale = 'test_locale_incorrect'; $this->makeLocaleDirectory(); - $status = service('commands')->run('lang:find', [ + $status = service('commands')->runCommand('lang:find', [], [ 'dir' => 'Translation', 'locale' => self::$locale, ]); @@ -108,7 +108,7 @@ public function testUpdateWithIncorrectDirOption(): void { $this->makeLocaleDirectory(); - $status = service('commands')->run('lang:find', [ + $status = service('commands')->runCommand('lang:find', [], [ 'dir' => 'Translation/NotExistFolder', ]); @@ -166,6 +166,9 @@ public function testWriteSkipsKeysAlreadyTranslatedByFramework(): void $this->assertArrayNotHasKey('pageNotFound', $generatedKeys); } + /** + * @return array + */ private function getActualTranslationOneKeys(): array { return [ @@ -179,6 +182,9 @@ private function getActualTranslationOneKeys(): array ]; } + /** + * @return array|string>> + */ private function getActualTranslationThreeKeys(): array { return [ @@ -212,6 +218,9 @@ private function getActualTranslationThreeKeys(): array ]; } + /** + * @return array> + */ private function getActualTranslationFourKeys(): array { return [ diff --git a/tests/system/Commands/Translation/LocalizationSyncTest.php b/tests/system/Commands/Translation/LocalizationSyncTest.php index a64105163b1c..515c42ac0f19 100644 --- a/tests/system/Commands/Translation/LocalizationSyncTest.php +++ b/tests/system/Commands/Translation/LocalizationSyncTest.php @@ -222,7 +222,7 @@ public function testSyncWithIncorrectTargetOption(): void public function testProcessWithInvalidOption(): void { $langPath = SUPPORTPATH . 'Language'; - $command = new LocalizationSync(service('logger'), service('commands')); + $command = new LocalizationSync(service('commands')); $this->setPrivateProperty($command, 'languagePath', $langPath); $runner = self::getPrivateMethodInvoker($command, 'process'); diff --git a/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php b/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php index ef8e43282b92..84f2106ccc21 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php @@ -34,6 +34,48 @@ public function testGet(): void $routes = $collector->get(); $expected = [ + [ + 'auto', + 'formRequestController', + '', + '\Tests\Support\Controllers\FormRequestController::index', + ], + [ + 'auto', + 'formRequestController/index[/...]', + '', + '\Tests\Support\Controllers\FormRequestController::index', + ], + [ + 'auto', + 'formRequestController/store[/...]', + '', + '\Tests\Support\Controllers\FormRequestController::store', + ], + [ + 'auto', + 'formRequestController/update[/...]', + '', + '\Tests\Support\Controllers\FormRequestController::update', + ], + [ + 'auto', + 'formRequestController/show[/...]', + '', + '\Tests\Support\Controllers\FormRequestController::show', + ], + [ + 'auto', + 'formRequestController/search[/...]', + '', + '\Tests\Support\Controllers\FormRequestController::search', + ], + [ + 'auto', + 'formRequestController/restricted[/...]', + '', + '\Tests\Support\Controllers\FormRequestController::restricted', + ], [ 'auto', 'hello', diff --git a/tests/system/Commands/Utilities/Routes/ControllerFinderTest.php b/tests/system/Commands/Utilities/Routes/ControllerFinderTest.php index 1f6ecce2ec3e..3fabbd9adcdd 100644 --- a/tests/system/Commands/Utilities/Routes/ControllerFinderTest.php +++ b/tests/system/Commands/Utilities/Routes/ControllerFinderTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Test\CIUnitTestCase; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Controllers\FormRequestController; use Tests\Support\Controllers\Hello; /** @@ -30,7 +31,8 @@ public function testFind(): void $controllers = $finder->find(); - $this->assertCount(4, $controllers); - $this->assertSame(Hello::class, $controllers[0]); + $this->assertCount(5, $controllers); + $this->assertSame(FormRequestController::class, $controllers[0]); + $this->assertSame(Hello::class, $controllers[1]); } } diff --git a/tests/system/Commands/Utilities/Routes/PlaceholderSampleGeneratorTest.php b/tests/system/Commands/Utilities/Routes/PlaceholderSampleGeneratorTest.php new file mode 100644 index 000000000000..14680f35d402 --- /dev/null +++ b/tests/system/Commands/Utilities/Routes/PlaceholderSampleGeneratorTest.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities\Routes; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class PlaceholderSampleGeneratorTest extends CIUnitTestCase +{ + #[DataProvider('provideGenerateProducesMatchingSample')] + public function testGenerateProducesMatchingSample(string $pattern, string $expected): void + { + $generator = new PlaceholderSampleGenerator(); + + $sample = $generator->generate($pattern); + + $this->assertSame($expected, $sample); + $this->assertMatchesRegularExpression('#^(?:' . $pattern . ')$#', $sample); + } + + /** + * @return iterable + */ + public static function provideGenerateProducesMatchingSample(): iterable + { + yield 'digit class' => ['[0-9]+', '0']; + + yield 'uppercase class' => ['[A-Z]+', 'A']; + + yield 'mixed class prefers letter' => ['[A-Z0-9]+', 'A']; + + yield 'code pattern from issue #9804' => ['[A-Z]{3}[0-9]+', 'AAA0']; + + yield 'fixed quantifier' => ['[a-z]{5}', 'aaaaa']; + + yield 'range quantifier uses min' => ['[a-z]{2,5}', 'aa']; + + yield 'star quantifier collapses' => ['[a-z]*abc', 'abc']; + + yield 'optional collapses' => ['a?bc', 'bc']; + + yield 'literal passthrough' => ['login', 'login']; + + yield 'escape backslash digit' => ['\\d{4}', '0000']; + + yield 'escape backslash word' => ['\\w+', 'a']; + + yield 'escape backslash non-digit' => ['\\D+', 'a']; + + yield 'escape backslash non-word' => ['\\W+', '-']; + + yield 'escape backslash whitespace' => ['\\s+', ' ']; + + yield 'escape backslash non-whitespace' => ['\\S+', 'a']; + + yield 'uuid (letters preferred)' => [ + '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + ]; + + yield 'anchored pattern tolerated' => ['^abc$', 'abc']; + + yield 'alternation picks first' => ['(?:foo|bar)', 'foo']; + + yield 'non-capturing group' => ['(?:x)+', 'x']; + + yield 'escape in class' => ['[A-Z\\-]+', 'A']; + + yield 'hyphen at end of class' => ['[A-Z-]+', 'A']; + + yield 'negated class avoids forbidden digits' => ['[^0-9]+', 'a']; + + yield 'negated class avoids forbidden letter' => ['[^a]+', 'A']; + + yield 'escaped digit in class' => ['[\\d]+', '0']; + + yield 'escaped word in class' => ['[\\w]+', 'a']; + + yield 'escaped whitespace in class' => ['[\\s]+', ' ']; + + yield 'punctuation-only class' => ['[._-]+', '.']; + + yield 'non-greedy star' => ['a*?', '']; + + yield 'non-greedy plus' => ['a+?', 'a']; + + yield 'possessive plus' => ['a++', 'a']; + + yield 'possessive star collapses' => ['a*+', '']; + + yield 'top-level alternation' => ['a|b', 'a']; + + yield 'top-level multi-branch alternation' => ['cat|dog|fish', 'cat']; + + yield 'escaped literal outside class' => ['a\\.b', 'a.b']; + + yield 'open-ended brace quantifier' => ['[a-z]{2,}', 'aa']; + + yield 'literal bracket first in class' => ['[]]', ']']; + + yield 'negated class with bracket first' => ['[^]]', 'a']; + + yield 'negated class forces digit' => ['[^a-zA-Z]+', '0']; + + yield 'empty pattern' => ['', '']; + } + + #[DataProvider('provideGenerateReturnsNullForUnsupportedPatterns')] + public function testGenerateReturnsNullForUnsupportedPatterns(string $pattern): void + { + $generator = new PlaceholderSampleGenerator(); + + $this->assertNull($generator->generate($pattern)); + } + + /** + * @return iterable + */ + public static function provideGenerateReturnsNullForUnsupportedPatterns(): iterable + { + yield 'lookahead' => ['(?=abc)abc']; + + yield 'lookbehind' => ['(?<=abc)def']; + + yield 'backreference' => ['(foo)\\1']; + + yield 'named group' => ['(?Pabc)']; + + yield 'negated class in class' => ['[\\D]+']; + + yield 'unclosed class' => ['[abc']; + + yield 'reversed range' => ['[z-a]']; + + yield 'dangling brace' => ['a{2']; + + yield 'stray closing paren' => ['abc)def']; + + yield 'metachar at atom position' => ['+abc']; + + yield 'trailing backslash' => ['abc\\']; + + yield 'unclosed escape in class' => ['[\\']; + + yield 'unclosed group' => ['(abc']; + + yield 'non-numeric brace body' => ['a{foo}']; + + yield 'negated class exhausts candidates' => ['[^aA0_-]']; + + yield 'hash breaks validation delimiter' => ['[A-Z]#[0-9]+']; + } + + public function testGenerateValidatesAgainstOriginalPattern(): void + { + $generator = new PlaceholderSampleGenerator(); + + // The dot atom emits 'a', which matches '.'. This sanity-checks the guard. + $this->assertSame('a', $generator->generate('.')); + } + + public function testGenerateReturnsEmptyStringForOptionalOnlyPattern(): void + { + $generator = new PlaceholderSampleGenerator(); + + $this->assertSame('', $generator->generate('[a-z]?')); + } +} diff --git a/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php b/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php index 1d67afae1ce4..9fd2ffd3bd45 100644 --- a/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php +++ b/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Commands\Utilities\Routes; use CodeIgniter\Test\CIUnitTestCase; +use Config\Routing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -23,6 +24,13 @@ #[Group('Others')] final class SampleURIGeneratorTest extends CIUnitTestCase { + protected function setUp(): void + { + parent::setUp(); + + $this->resetServices(); + } + #[DataProvider('provideGet')] public function testGet(string $routeKey, string $expected): void { @@ -35,18 +43,34 @@ public function testGet(string $routeKey, string $expected): void public static function provideGet(): iterable { - yield from [ - 'root' => ['/', '/'], - 'placeholder num' => ['shop/product/([0-9]+)', 'shop/product/123'], - 'placeholder segment' => ['shop/product/([^/]+)', 'shop/product/abc_123'], - 'placeholder any' => ['shop/product/(.*)', 'shop/product/123/abc'], - 'locale' => ['{locale}/home', 'en/home'], - 'locale segment' => ['{locale}/product/([^/]+)', 'en/product/abc_123'], - 'auto route' => ['home/index[/...]', 'home/index/1/2/3/4/5'], - ]; + yield 'root' => ['/', '/']; + + yield 'placeholder num' => ['shop/product/([0-9]+)', 'shop/product/123']; + + yield 'placeholder segment' => ['shop/product/([^/]+)', 'shop/product/abc_123']; + + yield 'placeholder any' => ['shop/product/(.*)', 'shop/product/123/abc']; + + yield 'locale' => ['{locale}/home', 'en/home']; + + yield 'locale segment' => ['{locale}/product/([^/]+)', 'en/product/abc_123']; + + yield 'auto route' => ['home/index[/...]', 'home/index/1/2/3/4/5']; } - public function testGetFromPlaceholderCustomPlaceholder(): void + public function testGetAutoGeneratesSampleForCustomPlaceholder(): void + { + $routes = service('routes'); + $routes->addPlaceholder('code', '[A-Z]{3}[0-9]+'); + + $generator = new SampleURIGenerator(); + + $uri = $generator->get('test/([A-Z]{3}[0-9]+)'); + + $this->assertSame('test/AAA0', $uri); + } + + public function testGetAutoGeneratesSampleForUuidPlaceholder(): void { $routes = service('routes'); $routes->addPlaceholder( @@ -59,6 +83,125 @@ public function testGetFromPlaceholderCustomPlaceholder(): void $routeKey = 'shop/product/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'; $uri = $generator->get($routeKey); - $this->assertSame('shop/product/::unknown::', $uri); + $this->assertSame('shop/product/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', $uri); + } + + public function testGetUsesConfiguredSampleForCustomPlaceholder(): void + { + $routes = service('routes'); + $routes->addPlaceholder('code', '[A-Z]{3}[0-9]+'); + + $config = new Routing(); + $config->placeholderSamples = ['code' => 'ABC123']; + + $generator = new SampleURIGenerator(null, $config); + + $uri = $generator->get('test/([A-Z]{3}[0-9]+)'); + + $this->assertSame('test/ABC123', $uri); + } + + public function testConfiguredSampleWinsOverAutoGeneration(): void + { + $routes = service('routes'); + $routes->addPlaceholder('code', '[A-Z]{3}[0-9]+'); + + $config = new Routing(); + $config->placeholderSamples = ['code' => 'XYZ42']; + + $generator = new SampleURIGenerator(null, $config); + + $uri = $generator->get('test/([A-Z]{3}[0-9]+)'); + + $this->assertSame('test/XYZ42', $uri); + } + + public function testGetMemoizesSamplesAcrossCalls(): void + { + $routes = service('routes'); + $routes->addPlaceholder('code', '[A-Z]{3}[0-9]+'); + + $generator = new SampleURIGenerator(); + + $this->assertSame('test/AAA0', $generator->get('test/([A-Z]{3}[0-9]+)')); + $this->assertSame('other/AAA0', $generator->get('other/([A-Z]{3}[0-9]+)')); + } + + public function testGetFallsBackToUnknownForUnresolvablePlaceholder(): void + { + $routes = service('routes'); + // A lookahead is unsupported, so expect the fallback sentinel. + $routes->addPlaceholder('guarded', '(?=abc)abc'); + + $generator = new SampleURIGenerator(); + + $uri = $generator->get('x/((?=abc)abc)'); + + $this->assertSame('x/' . SampleURIGenerator::UNKNOWN_SAMPLE, $uri); + } + + public function testConfiguredSampleWinsOverBuiltIn(): void + { + $config = new Routing(); + $config->placeholderSamples = ['num' => '987']; + + $generator = new SampleURIGenerator(null, $config); + + $uri = $generator->get('shop/product/([0-9]+)'); + + $this->assertSame('shop/product/987', $uri); + } + + public function testBuiltInSampleWinsOverAutoGeneration(): void + { + $generator = new SampleURIGenerator(); + + // The built-in sample (123) is preferred over the value the regex + // generator would produce for [0-9]+ (0). + $this->assertSame('shop/product/123', $generator->get('shop/product/([0-9]+)')); + } + + public function testNonMatchingConfiguredSampleIsIgnored(): void + { + $routes = service('routes'); + $routes->addPlaceholder('code', '[A-Z]{3}[0-9]+'); + + $config = new Routing(); + $config->placeholderSamples = ['code' => 'does not match']; + + $generator = new SampleURIGenerator(null, $config); + + $uri = $generator->get('test/([A-Z]{3}[0-9]+)'); + + $this->assertSame('test/AAA0', $uri); + } + + public function testEmptyConfiguredSampleIsIgnored(): void + { + $routes = service('routes'); + $routes->addPlaceholder('code', '[A-Z]{3}[0-9]+'); + + $config = new Routing(); + $config->placeholderSamples = ['code' => '']; + + $generator = new SampleURIGenerator(null, $config); + + $uri = $generator->get('test/([A-Z]{3}[0-9]+)'); + + $this->assertSame('test/AAA0', $uri); + } + + public function testOverriddenBuiltInPlaceholderIgnoresStaleSample(): void + { + $routes = service('routes'); + // Redefining a built-in name with a new regex must not reuse the stale + // built-in sample (123), which no longer matches. + $routes->addPlaceholder('num', '[A-Z]+'); + + $generator = new SampleURIGenerator(); + + $uri = $generator->get('shop/product/([A-Z]+)'); + + $this->assertSame('shop/product/A', $uri); } } diff --git a/tests/system/Commands/Utilities/RoutesTest.php b/tests/system/Commands/Utilities/RoutesTest.php index 1d433242846b..df54ece28a6c 100644 --- a/tests/system/Commands/Utilities/RoutesTest.php +++ b/tests/system/Commands/Utilities/RoutesTest.php @@ -117,41 +117,6 @@ public function testRoutesCommandSortByHandler(): void $this->assertStringContainsString($expected, $this->getBuffer()); } - /** - * @todo To remove this test and the backward compatibility for -h in v4.8.0. - */ - public function testRoutesCommandSortByHandlerUsingShortcutForBc(): void - { - Services::resetSingle('routes'); - - command('routes -h'); - - $expected = <<<'EOL' - Warning: -h will be used as shortcut for --help in v4.8.0. Please use --sort-by-handler to sort by handler. - - +---------+---------+---------------+----------------------------------------+----------------+---------------+ - | Method | Route | Name | Handler ↓ | Before Filters | After Filters | - +---------+---------+---------------+----------------------------------------+----------------+---------------+ - | GET | closure | » | (Closure) | | | - | GET | / | » | \App\Controllers\Home::index | | | - | GET | testing | testing-index | \App\Controllers\TestController::index | | | - | HEAD | testing | testing-index | \App\Controllers\TestController::index | | | - | POST | testing | testing-index | \App\Controllers\TestController::index | | | - | PATCH | testing | testing-index | \App\Controllers\TestController::index | | | - | PUT | testing | testing-index | \App\Controllers\TestController::index | | | - | DELETE | testing | testing-index | \App\Controllers\TestController::index | | | - | OPTIONS | testing | testing-index | \App\Controllers\TestController::index | | | - | TRACE | testing | testing-index | \App\Controllers\TestController::index | | | - | CONNECT | testing | testing-index | \App\Controllers\TestController::index | | | - | CLI | testing | testing-index | \App\Controllers\TestController::index | | | - +---------+---------+---------------+----------------------------------------+----------------+---------------+ - EOL; - $this->assertStringContainsString( - $expected, - (string) preg_replace('/\e\[[^m]+m/u', '', $this->getBuffer()), - ); - } - public function testRoutesCommandHostHostname(): void { Services::resetSingle('routes'); diff --git a/tests/system/Commands/Worker/WorkerCommandsTest.php b/tests/system/Commands/Worker/WorkerCommandsTest.php index ff570356fdcf..7e5a494039b4 100644 --- a/tests/system/Commands/Worker/WorkerCommandsTest.php +++ b/tests/system/Commands/Worker/WorkerCommandsTest.php @@ -13,8 +13,11 @@ namespace CodeIgniter\Commands\Worker; +use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -38,6 +41,13 @@ protected function tearDown(): void parent::tearDown(); $this->cleanupFiles(); + + CLI::reset(); + } + + private function getUndecoratedOutput(string $output): string + { + return preg_replace('/\e\[[^m]+m/', '', $output) ?? ''; } private function cleanupFiles(): void @@ -141,6 +151,68 @@ public function testWorkerUninstallListsFilesToRemove(): void $this->assertStringContainsString('Caddyfile', $output); } + public function testWorkerUninstallCancelsWithoutForce(): void + { + command('worker:install'); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + command('worker:uninstall'); + + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + + $output = $this->getUndecoratedOutput($io->getOutput()); + $this->assertStringContainsString('Remove the FrankenPHP worker mode files? [y, n]: n', $output); + $this->assertStringContainsString('Uninstall cancelled.', $output); + } + + public function testWorkerUninstallWithoutForceButConfirmed(): void + { + command('worker:install'); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + command('worker:uninstall'); + + $this->assertFileDoesNotExist(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileDoesNotExist(ROOTPATH . 'Caddyfile'); + + $output = $this->getUndecoratedOutput($io->getOutput()); + $this->assertStringContainsString('Remove the FrankenPHP worker mode files? [y, n]: y', $output); + $this->assertStringContainsString('Worker mode files removed successfully!', $output); + } + + #[DataProvider('provideWorkerUninstallAbortsNonInteractively')] + public function testWorkerUninstallAbortsNonInteractively(string $flag): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command("worker:uninstall {$flag}"); + + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + $this->assertStringContainsString( + 'Uninstall aborted: pass --force to remove worker mode files in non-interactive mode.', + $this->getStreamFilterBuffer(), + ); + } + + /** + * @return iterable + */ + public static function provideWorkerUninstallAbortsNonInteractively(): iterable + { + yield 'long form' => ['--no-interaction']; + + yield 'short form' => ['-N']; + } + public function testWorkerInstallAndUninstallCycle(): void { command('worker:install'); diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 83750c220bc4..7594418cdd31 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -502,7 +502,7 @@ public function testOldInput(): void 'zibble' => 'fritz', ]); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $response->withInput(); $this->assertSame('bar', old('foo')); // regular parameter @@ -536,7 +536,7 @@ public function testOldInputSerializeData(): void 'zibble' => serialize('fritz'), ]); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $response->withInput(); // serialized parameters are only HTML-escaped. @@ -576,7 +576,7 @@ public function testOldInputArray(): void $superglobals->setGetArray([]); $superglobals->setPostArray(['location' => $locations]); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $response->withInput(); $this->assertSame($locations, old('location')); @@ -875,4 +875,11 @@ public function testRenderBacktrace(): void $this->assertMatchesRegularExpression('/^\s*\d* .+(?:\(\d+\))?: \S+(?:(?:\->|::)\S+)?\(.*\)$/', $render); } } + + public function testContext(): void + { + service('context')->set('foo', 'bar'); + + $this->assertSame('bar', context()->get('foo')); + } } diff --git a/tests/system/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index 829f0f6e2d96..6baedae46cc0 100644 --- a/tests/system/CommonSingleServiceTest.php +++ b/tests/system/CommonSingleServiceTest.php @@ -60,7 +60,13 @@ public function testSingleServiceWithAtLeastOneParamSupplied(string $service): v $params = []; $method = new ReflectionMethod(Services::class, $service); - $params[] = $method->getNumberOfParameters() === 1 ? true : $method->getParameters()[0]->getDefaultValue(); + $count = $method->getNumberOfParameters(); + + if ($count === 0) { + $this->markTestSkipped("Service '{$service}' does not have any parameters."); + } + + $params[] = $count === 1 ? true : $method->getParameters()[0]->getDefaultValue(); $service1 = single_service($service, ...$params); $service2 = single_service($service, ...$params); diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index 24d259feef26..cb3503c3fb8a 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; use Encryption; +use MergeRegistrarConfig; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -27,6 +28,11 @@ use RegistrarConfig; use SimpleConfig; use Tests\Support\Config\BadRegistrar; +use Tests\Support\Config\MergeOrderRegistrar; +use Tests\Support\Config\MergePlainRegistrar; +use Tests\Support\Config\MergeRegistrar; +use Tests\Support\Config\MergeRegistrarA; +use Tests\Support\Config\MergeRegistrarB; use Tests\Support\Config\TestRegistrar; /** @@ -53,6 +59,10 @@ protected function setUp(): void require $this->fixturesFolder . '/RegistrarConfig.php'; } + if (! class_exists('MergeRegistrarConfig', false)) { + require $this->fixturesFolder . '/MergeRegistrarConfig.php'; + } + if (! class_exists('Encryption', false)) { require $this->fixturesFolder . '/Encryption.php'; } @@ -293,6 +303,83 @@ public function testRegistrars(): void $this->assertSame(['baz', 'first', 'second'], $config->bar); } + /** + * @param list $registrars + */ + private function registerMerge(MergeRegistrarConfig $config, array $registrars): void + { + $config::$registrars = $registrars; + $this->setPrivateProperty($config, 'didDiscovery', true); + $method = self::getPrivateMethodInvoker($config, 'registerProperties'); + $method(); + } + + public function testMergePlainRegistrarKeepsLegacyShallowMerge(): void + { + // BC regression: a plain-array registrar still drops nested siblings. + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergePlainRegistrar::class]); + + $this->assertSame([ + 'key1' => 'val1', + 'key2' => ['val4' => 'subVal4'], + ], $config->arrayNested); + } + + public function testMergeByKeyDeepMerges(): void + { + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergeRegistrar::class]); + + // Example A - siblings preserved. + $this->assertSame([ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3', 'val4' => 'subVal4'], + ], $config->arrayNested); + + // Example B - list grows. + $this->assertSame(['superadmin' => ['admin.access', 'shippinglabel-logos.*']], $config->matrix); + + // Example C - nested directives resolved. + $this->assertSame(['before' => ['csrf', 'blogFilter'], 'after' => []], $config->globals); + } + + public function testMergeAppendAndReplaceAtPropertyRoot(): void + { + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergeRegistrar::class]); + + $this->assertSame(['a', 'b', 'c'], $config->list); + $this->assertSame('redis', $config->handler); + } + + public function testMergeOrderingDirectivesThroughRegistrar(): void + { + $config = new MergeRegistrarConfig(); + $this->registerMerge($config, [MergeOrderRegistrar::class]); + + // Nested ordering inside byKey(): auth lands after csrf, honeypot at the front. + $this->assertSame([ + 'before' => ['csrf', 'auth'], + 'after' => ['honeypot', 'toolbar'], + ], $config->globals); + + // Property-root ordering. + $this->assertSame(['z', 'a', 'b'], $config->list); + } + + public function testMultipleRegistrarsOnSameProperty(): void + { + $config = new MergeRegistrarConfig(); + // Two registrars both append to list and both replace handler. + $this->registerMerge($config, [MergeRegistrarA::class, MergeRegistrarB::class]); + + // append() accumulates in registrar (discovery) order. + $this->assertSame(['a', 'b', 'x', 'y'], $config->list); + // competing replace() calls resolve in registrar order - last wins. + $this->assertSame('memcached', $config->handler); + } + public function testBadRegistrar(): void { // Shouldn't change any values. diff --git a/tests/system/Config/MergeTest.php b/tests/system/Config/MergeTest.php new file mode 100644 index 000000000000..2b3ccf75685a --- /dev/null +++ b/tests/system/Config/MergeTest.php @@ -0,0 +1,388 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +use Closure; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; + +/** + * Exercises the merge engine (applyMerge()/mergeByKey()) directly, independent + * of registrar discovery. + * + * @internal + */ +#[Group('Others')] +final class MergeTest extends CIUnitTestCase +{ + /** + * @var Closure(mixed, Merge): mixed + */ + private Closure $applyMerge; + + protected function setUp(): void + { + parent::setUp(); + + // Build the config without running the constructor so registrar + // discovery is never triggered for these pure engine tests. + $config = (new ReflectionClass(BaseConfig::class))->newInstanceWithoutConstructor(); + + $this->applyMerge = self::getPrivateMethodInvoker($config, 'applyMerge'); + } + + private function apply(mixed $current, Merge $directive): mixed + { + return ($this->applyMerge)($current, $directive); + } + + public function testReplaceArray(): void + { + $this->assertSame(['c'], $this->apply(['a', 'b'], Merge::replace(['c']))); + } + + public function testReplaceScalar(): void + { + $this->assertSame('redis', $this->apply('file', Merge::replace('redis'))); + } + + public function testReplaceBool(): void + { + $this->assertFalse($this->apply(true, Merge::replace(false))); + } + + public function testReplaceNull(): void + { + $this->assertNull($this->apply('something', Merge::replace(null))); + } + + public function testAppend(): void + { + $this->assertSame(['a', 'b', 'c'], $this->apply(['a', 'b'], Merge::append(['c']))); + } + + public function testAppendOntoNonArrayCurrent(): void + { + $this->assertSame(['c'], $this->apply('scalar', Merge::append(['c']))); + $this->assertSame(['c'], $this->apply(null, Merge::append(['c']))); + } + + public function testAppendDeDups(): void + { + // A value already present is not duplicated; only the absent one is added. + $this->assertSame(['a', 'b', 'c'], $this->apply(['a', 'b'], Merge::append(['b', 'c']))); + } + + public function testAppendDeDupsWithinPayload(): void + { + // A value repeated inside the payload is added only once. + $this->assertSame(['a', 'x'], $this->apply(['a'], Merge::append(['x', 'x']))); + } + + public function testListOpsLeavePreExistingDuplicatesUntouched(): void + { + $this->assertSame(['a', 'a', 'b'], $this->apply(['a', 'a'], Merge::append(['b']))); + } + + public function testPrepend(): void + { + $this->assertSame(['c', 'a', 'b'], $this->apply(['a', 'b'], Merge::prepend(['c']))); + } + + public function testPrependDeDupsAndDoesNotMove(): void + { + // 'a' is already present, so it is left where it is, not moved to the front. + $this->assertSame(['x', 'a', 'b'], $this->apply(['a', 'b'], Merge::prepend(['a', 'x']))); + } + + public function testBeforeAnchorFound(): void + { + $base = ['csrf', 'invalidchars', 'toolbar']; + $this->assertSame( + ['csrf', 'invalidchars', 'auth', 'toolbar'], + $this->apply($base, Merge::before('toolbar', ['auth'])), + ); + } + + public function testAfterAnchorFound(): void + { + $base = ['csrf', 'invalidchars', 'toolbar']; + $this->assertSame( + ['csrf', 'auth', 'invalidchars', 'toolbar'], + $this->apply($base, Merge::after('csrf', ['auth'])), + ); + } + + public function testAfterMovesAnAlreadyPresentValue(): void + { + // auth exists before toolbar; after('toolbar', ['auth']) relocates it. + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame( + ['csrf', 'toolbar', 'auth'], + $this->apply($base, Merge::after('toolbar', ['auth'])), + ); + } + + public function testBeforeMovesAnAlreadyPresentValue(): void + { + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame( + ['toolbar', 'csrf', 'auth'], + $this->apply($base, Merge::before('csrf', ['toolbar'])), + ); + } + + public function testAfterDeDupsWithinPayloadPreservingOrder(): void + { + // Repeated payload values collapse to first-seen order before insertion. + $base = ['csrf', 'toolbar']; + $this->assertSame( + ['csrf', 'a', 'b', 'toolbar'], + $this->apply($base, Merge::after('csrf', ['a', 'b', 'a'])), + ); + } + + public function testAfterPerValueMix(): void + { + // auth present (moved), newFilter absent (inserted) - as one block after csrf. + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame( + ['csrf', 'auth', 'newFilter', 'toolbar'], + $this->apply($base, Merge::after('csrf', ['auth', 'newFilter'])), + ); + } + + public function testAfterUsesFirstAnchorMatch(): void + { + $base = ['csrf', 'auth', 'csrf']; + $this->assertSame( + ['csrf', 'x', 'auth', 'csrf'], + $this->apply($base, Merge::after('csrf', ['x'])), + ); + } + + public function testAfterMissingAnchorFallsBackToAppend(): void + { + $base = ['csrf', 'auth']; + $this->assertSame( + ['csrf', 'auth', 'newFilter'], + $this->apply($base, Merge::after('honeypot', ['newFilter'])), + ); + } + + public function testBeforeMissingAnchorFallsBackToPrepend(): void + { + $base = ['csrf', 'auth']; + $this->assertSame( + ['newFilter', 'csrf', 'auth'], + $this->apply($base, Merge::before('honeypot', ['newFilter'])), + ); + } + + public function testMissingAnchorDoesNotRelocateAPresentValue(): void + { + // honeypot is absent and auth is already present → leave the list as-is. + $base = ['csrf', 'auth', 'toolbar']; + $this->assertSame($base, $this->apply($base, Merge::after('honeypot', ['auth']))); + } + + public function testListOpOntoNonArrayCurrent(): void + { + $this->assertSame(['auth'], $this->apply(null, Merge::after('csrf', ['auth']))); + $this->assertSame(['auth'], $this->apply('scalar', Merge::before('csrf', ['auth']))); + } + + public function testRepeatedSameAnchorAfterLandsCloserToAnchor(): void + { + // Two registrars anchoring after('csrf', …) in turn: the later one lands + // closer to the anchor (documented contract). + $first = $this->apply(['csrf'], Merge::after('csrf', ['a'])); + $second = $this->apply($first, Merge::after('csrf', ['b'])); + + $this->assertSame(['csrf', 'b', 'a'], $second); + } + + public function testRepeatedSameAnchorBeforeLandsCloserToAnchor(): void + { + $first = $this->apply(['csrf'], Merge::before('csrf', ['a'])); + $second = $this->apply($first, Merge::before('csrf', ['b'])); + + $this->assertSame(['a', 'b', 'csrf'], $second); + } + + public function testBeforeRejectsAnchorInPayload(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Merge::before() cannot use a value that is also being inserted as its anchor.'); + + Merge::before('csrf', ['csrf']); + } + + public function testAfterRejectsAnchorInPayload(): void + { + $this->expectException(InvalidArgumentException::class); + + Merge::after('csrf', ['x', 'csrf']); + } + + public function testByKeyStringKeysRecurse(): void + { + // Example A - siblings preserved. + $current = [ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'], + ]; + $result = $this->apply($current, Merge::byKey([ + 'key2' => ['val4' => 'subVal4'], + ])); + + $this->assertSame([ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3', 'val4' => 'subVal4'], + ], $result); + } + + public function testByKeyIntegerKeysAppend(): void + { + // Example B - Shield matrix superadmin list grows. + $current = ['superadmin' => ['admin.access']]; + $result = $this->apply($current, Merge::byKey([ + 'superadmin' => ['shippinglabel-logos.*'], + ])); + + $this->assertSame(['superadmin' => ['admin.access', 'shippinglabel-logos.*']], $result); + } + + public function testByKeyScalarLeafReplace(): void + { + $this->assertSame(['x' => 2], $this->apply(['x' => 1], Merge::byKey(['x' => 2]))); + } + + public function testByKeyNestedDirectives(): void + { + // Example C - nested append()/replace() resolved, untouched sibling kept. + $current = [ + 'before' => ['csrf'], + 'after' => ['toolbar'], + 'other' => ['keep'], + ]; + $result = $this->apply($current, Merge::byKey([ + 'before' => Merge::append(['blogFilter']), + 'after' => Merge::replace([]), + ])); + + $this->assertSame([ + 'before' => ['csrf', 'blogFilter'], + 'after' => [], + 'other' => ['keep'], + ], $result); + } + + public function testByKeyWithNestedOrderingDirectives(): void + { + // The realistic Filters case: order a filter relative to an existing one + // inside the nested 'before'/'after' lists of 'globals'. + $current = [ + 'before' => ['csrf', 'invalidchars'], + 'after' => ['toolbar'], + ]; + $result = $this->apply($current, Merge::byKey([ + 'before' => Merge::after('csrf', ['auth']), + 'after' => Merge::prepend(['honeypot']), + ])); + + $this->assertSame([ + 'before' => ['csrf', 'auth', 'invalidchars'], + 'after' => ['honeypot', 'toolbar'], + ], $result); + } + + public function testByKeyDirectiveInBrandNewSubtree(): void + { + // A directive under a key absent from the base resolves against an empty base. + $result = $this->apply(['existing' => 1], Merge::byKey([ + 'newKey' => Merge::append(['x']), + ])); + + // The directive under the brand-new key is resolved, not stored literally. + $this->assertSame(['existing' => 1, 'newKey' => ['x']], $result); + } + + public function testByKeyDirectiveAtIntegerKeyAppends(): void + { + $result = $this->apply(['a'], Merge::byKey([ + Merge::replace('b'), + ])); + + $this->assertSame(['a', 'b'], $result); + } + + public function testByKeyResolvesBrandNewNestedArraySubtree(): void + { + // A string key missing from the base recurses with [] as its base. + $result = $this->apply([], Merge::byKey([ + 'deep' => ['nested' => ['value']], + ])); + + $this->assertSame(['deep' => ['nested' => ['value']]], $result); + } + + public function testByKeyResolvesDirectiveInsideBrandNewNestedArraySubtree(): void + { + $result = $this->apply([], Merge::byKey([ + 'globals' => [ + 'before' => Merge::append(['auth']), + ], + ])); + + $this->assertSame(['globals' => ['before' => ['auth']]], $result); + } + + public function testAppendPayloadIsTerminalLiteral(): void + { + // A directive embedded in an append() payload is literal data, not interpreted. + $result = $this->apply([], Merge::append([Merge::replace('x')])); + + $this->assertInstanceOf(Merge::class, $result[0]); + } + + public function testReplacePayloadIsTerminalLiteral(): void + { + $payload = ['nested' => Merge::append(['x'])]; + $result = $this->apply(['old'], Merge::replace($payload)); + + $this->assertInstanceOf(Merge::class, $result['nested']); + } + + public function testFactoriesSetStrategyAndValue(): void + { + $this->assertSame(Merge::REPLACE, Merge::replace('v')->strategy); + $this->assertSame(Merge::APPEND, Merge::append(['v'])->strategy); + $this->assertSame(Merge::PREPEND, Merge::prepend(['v'])->strategy); + $this->assertSame(Merge::BEFORE, Merge::before('a', ['v'])->strategy); + $this->assertSame(Merge::AFTER, Merge::after('a', ['v'])->strategy); + $this->assertSame(Merge::BY_KEY, Merge::byKey(['v'])->strategy); + $this->assertSame('v', Merge::replace('v')->value); + } + + public function testFactoriesSetAnchor(): void + { + $this->assertSame('csrf', Merge::before('csrf', ['v'])->anchor); + $this->assertSame('csrf', Merge::after('csrf', ['v'])->anchor); + // Non-anchored directives carry a null anchor. + $this->assertNull(Merge::append(['v'])->anchor); + } +} diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 86228dfadd3f..09c9fe8cd832 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -31,7 +31,9 @@ use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\URI; use CodeIgniter\Images\ImageHandlerInterface; +use CodeIgniter\Input\InputDataFactory; use CodeIgniter\Language\Language; +use CodeIgniter\Lock\LockManager; use CodeIgniter\Pager\Pager; use CodeIgniter\Router\RouteCollection; use CodeIgniter\Router\Router; @@ -46,7 +48,7 @@ use CodeIgniter\Validation\Validation; use CodeIgniter\View\Cell; use CodeIgniter\View\Parser; -use Config\App; +use Config\Cache; use Config\Database as DatabaseConfig; use Config\Exceptions; use Config\Security as SecurityConfig; @@ -108,6 +110,44 @@ public function testNewFileLocator(): void $this->assertInstanceOf(FileLocator::class, $actual); } + public function testNewLocks(): void + { + $config = new Cache(); + $config->file['storePath'] = WRITEPATH . 'cache/ServicesLockTest'; + + if (! is_dir($config->file['storePath'])) { + mkdir($config->file['storePath'], 0777, true); + } + + try { + $actual = Services::locks(Services::cache($config, false)); + $this->assertInstanceOf(LockManager::class, $actual); + } finally { + delete_files($config->file['storePath'], false, true); + rmdir($config->file['storePath']); + } + } + + public function testNewUnsharedLocksWithCustomCache(): void + { + $config = new Cache(); + $config->file['storePath'] = WRITEPATH . 'cache/ServicesLockTest'; + + if (! is_dir($config->file['storePath'])) { + mkdir($config->file['storePath'], 0777, true); + } + + try { + $custom = Services::cache($config, false); + + $this->assertInstanceOf(LockManager::class, Services::locks($custom, false)); + $this->assertNotSame(Services::locks($custom, false), Services::locks($custom, false)); + } finally { + delete_files($config->file['storePath'], false, true); + rmdir($config->file['storePath']); + } + } + public function testNewUnsharedFileLocator(): void { $actual = Services::locator(false); @@ -236,6 +276,15 @@ public function testNewValidation(): void $this->assertInstanceOf(Validation::class, $actual); } + public function testNewInputDataFactory(): void + { + $actual = Services::inputdatafactory(); + + $this->assertInstanceOf(InputDataFactory::class, $actual); + $this->assertSame($actual, Services::inputdatafactory()); + $this->assertNotSame($actual, Services::inputdatafactory(false)); + } + public function testNewViewcellFromShared(): void { $actual = Services::viewcell(); @@ -334,11 +383,11 @@ public function testCallStaticDirectly(): void #[RunInSeparateProcess] public function testMockInjection(): void { - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $response = service('response'); $this->assertInstanceOf(MockResponse::class, $response); - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $response2 = service('response'); $this->assertInstanceOf(MockResponse::class, $response2); @@ -354,13 +403,13 @@ public function testMockInjection(): void #[RunInSeparateProcess] public function testReset(): void { - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $response = service('response'); $this->assertInstanceOf(MockResponse::class, $response); Services::reset(true); // reset mocks & shared instances - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $response2 = service('response'); $this->assertInstanceOf(MockResponse::class, $response2); @@ -371,7 +420,7 @@ public function testReset(): void #[RunInSeparateProcess] public function testResetSingle(): void { - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); Services::injectMock('security', new MockSecurity(new SecurityConfig())); $response = service('response'); $security = service('security'); @@ -391,7 +440,7 @@ public function testResetSingle(): void public function testResetSingleCaseInsensitive(): void { - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $someService = service('response'); $this->assertInstanceOf(MockResponse::class, $someService); @@ -404,7 +453,7 @@ public function testResetSingleCaseInsensitive(): void #[RunInSeparateProcess] public function testResetServiceCache(): void { - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $response = service('response'); $this->assertInstanceOf(MockResponse::class, $response); service('response')->setStatusCode(200); diff --git a/tests/system/Config/fixtures/MergeRegistrarConfig.php b/tests/system/Config/fixtures/MergeRegistrarConfig.php new file mode 100644 index 000000000000..71cb6ce20806 --- /dev/null +++ b/tests/system/Config/fixtures/MergeRegistrarConfig.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use CodeIgniter\Config\BaseConfig; + +class MergeRegistrarConfig extends BaseConfig +{ + /** + * @var array + */ + public array $arrayNested = [ + 'key1' => 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'], + ]; + + /** + * @var array> + */ + public array $matrix = [ + 'superadmin' => ['admin.access'], + ]; + + /** + * @var array> + */ + public array $globals = [ + 'before' => ['csrf'], + 'after' => ['toolbar'], + ]; + + public string $handler = 'file'; + + /** + * @var list + */ + public array $list = ['a', 'b']; +} diff --git a/tests/system/Context/ContextTest.php b/tests/system/Context/ContextTest.php new file mode 100644 index 000000000000..daef7cdb312d --- /dev/null +++ b/tests/system/Context/ContextTest.php @@ -0,0 +1,837 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Context; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ContextTest extends CIUnitTestCase +{ + public function testInitialState(): void + { + $context = single_service('context'); + $this->assertSame([], $context->getAll()); + $this->assertSame([], $context->getAllHidden()); + } + + public function testSetAndGetSingleValue(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + + $this->assertSame(123, $context->get('user_id')); + $this->assertNull($context->getHidden('user_id')); // Normal value should not be retrievable with getHidden() + } + + public function testSetAndGetSingleValueWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.id', 123); + + $this->assertSame(123, $context->get('user.id')); + $this->assertNull($context->getHidden('user.id')); // Normal value should not be retrievable with getHidden() + } + + public function testSetAndGetMultipleValues(): void + { + $context = single_service('context'); + $context->set([ + 'user_id' => 123, + 'username' => 'john_doe', + ]); + + $this->assertSame(123, $context->get('user_id')); + $this->assertSame('john_doe', $context->get('username')); + $this->assertNull($context->getHidden('user_id')); + $this->assertNull($context->getHidden('username')); + } + + public function testSetAndGetMultipleValueWithDotNotation(): void + { + $context = single_service('context'); + $context->set([ + 'user.profile.name' => 'John Doe', + 'user.profile.email' => 'john@example.com', + ]); + + $this->assertSame('John Doe', $context->get('user.profile.name')); + $this->assertSame('john@example.com', $context->get('user.profile.email')); + $this->assertNull($context->getHidden('user.profile.name')); // Normal value should not be retrievable with getHidden() + } + + public function testSetAndGetSingleHiddenValue(): void + { + $context = single_service('context'); + $context->setHidden('api_key', 'secret'); + + $this->assertSame('secret', $context->getHidden('api_key')); + $this->assertNull($context->get('api_key')); // Hidden value should not be retrievable with get() + } + + public function testSetAndGetSingleHiddenValueWithDotNotation(): void + { + $context = single_service('context'); + $context->setHidden('api.credentials.key', 'secret'); + + $this->assertSame('secret', $context->getHidden('api.credentials.key')); + $this->assertNull($context->get('api.credentials.key')); // Hidden value should not be retrievable with get() + } + + public function testSetAndGetMultipleHiddenValues(): void + { + $context = single_service('context'); + $context->setHidden([ + 'api_key' => 'secret', + 'token' => 'abc123', + ]); + + $this->assertSame('secret', $context->getHidden('api_key')); + $this->assertSame('abc123', $context->getHidden('token')); + $this->assertNull($context->get('api_key')); + $this->assertNull($context->get('token')); + } + + public function testSetAndGetMultipleHiddenValuesWithDotNotation(): void + { + $context = single_service('context'); + $context->setHidden([ + 'api.credentials.key' => 'secret', + 'api.credentials.token' => 'abc123', + ]); + + $this->assertSame('secret', $context->getHidden('api.credentials.key')); + $this->assertSame('abc123', $context->getHidden('api.credentials.token')); + $this->assertNull($context->get('api.credentials.key')); + $this->assertNull($context->get('api.credentials.token')); + } + + public function testClear(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->set('username', 'john_doe'); + + $context->clear(); + + $this->assertNull($context->get('user_id')); + $this->assertNull($context->get('username')); + } + + public function testClearWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->set('user.profile.email', 'john@example.com'); + + $context->clear(); + + $this->assertNull($context->get('user.profile.name')); + $this->assertNull($context->get('user.profile.email')); + } + + public function testClearDoesntAffectHidden(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api_key', 'secret123'); + + $context->clear(); + + $this->assertNull($context->get('user_id')); + $this->assertSame('secret123', $context->getHidden('api_key')); // Hidden value should still be retrievable after clear() + } + + public function testClearWithDotNotationDoesntAffectHidden(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->setHidden('api.credentials.key', 'secret123'); + + $context->clear(); + + $this->assertNull($context->get('user.profile.name')); + $this->assertSame('secret123', $context->getHidden('api.credentials.key')); // Hidden value should still be retrievable after clear() + } + + public function testClearHidden(): void + { + $context = single_service('context'); + $context->setHidden('api_key', 'abcdef'); + $context->setHidden('token', 'abc123'); + + $context->clearHidden(); + + $this->assertNull($context->getHidden('api_key')); + $this->assertNull($context->getHidden('token')); + } + + public function testClearHiddenWithDotNotation(): void + { + $context = single_service('context'); + $context->setHidden('api.credentials.key', 'abcdef'); + $context->setHidden('api.credentials.token', 'abc123'); + + $context->clearHidden(); + + $this->assertNull($context->getHidden('api.credentials.key')); + $this->assertNull($context->getHidden('api.credentials.token')); + } + + public function testClearHiddenDoesntAffectNormalValues(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api_key', 'secret123'); + + $context->clearHidden(); + + $this->assertSame(123, $context->get('user_id')); // Normal value should still be retrievable after clearHidden() + $this->assertNull($context->getHidden('api_key')); // Hidden value should be cleared + } + + public function testClearHiddenWithDotNotationDoesntAffectNormalValues(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->setHidden('api.credentials.key', 'secret123'); + + $context->clearHidden(); + + $this->assertSame('John Doe', $context->get('user.profile.name')); // Normal value should still be retrievable after clearHidden() + $this->assertNull($context->getHidden('api.credentials.key')); // Hidden value should be cleared + } + + public function testClearAll(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api_key', 'secret'); + + $context->clearAll(); + + $this->assertNull($context->get('user_id')); + $this->assertNull($context->getHidden('api_key')); + } + + public function testClearAllWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->setHidden('api.credentials.key', 'secret'); + + $context->clearAll(); + + $this->assertNull($context->get('user.profile.name')); + $this->assertNull($context->getHidden('api.credentials.key')); + } + + public function testGetWithDefaultValue(): void + { + $context = single_service('context'); + + $context->set('user_id', 123); + + $this->assertSame(123, $context->get('user_id', 'default')); // Existing key should return its value, not the default + $this->assertSame('default', $context->get('non_existent_key', 'default')); + } + + public function testGetWithDotNotationAndDefaultValue(): void + { + $context = single_service('context'); + + $context->set('user.profile.name', 'John Doe'); + + $this->assertSame('John Doe', $context->get('user.profile.name', 'default')); // Existing key should return its value, not the default + $this->assertSame('default', $context->get('user.profile.non_existent_key', 'default')); + } + + public function testGetOnlySingleKey(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->set('username', 'john_doe'); + $context->setHidden('api_key', 'secret'); + + $this->assertSame(['user_id' => 123], $context->getOnly('user_id')); + $this->assertSame(['username' => 'john_doe'], $context->getOnly('username')); + $this->assertSame([], $context->getOnly('non_existent_key')); + } + + public function testGetOnlySingleKeyWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->setHidden('api.credentials.key', 'secret'); + + $this->assertSame([ + 'user' => [ + 'profile' => [ + 'name' => 'John Doe', + ], + ], + ], $context->getOnly('user.profile.name')); + $this->assertSame([], $context->getOnly('user.profile.non_existent_key')); + } + + public function testGetOnlyMultipleKeys(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->set('username', 'john_doe'); + $context->setHidden('api_key', 'secret'); + + $expected = [ + 'user_id' => 123, + 'username' => 'john_doe', + ]; + $this->assertSame($expected, $context->getOnly(['user_id', 'username', 'non_existent_key'])); // non_existent_key should be ignored + } + + public function testGetOnlyMultipleKeysWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->set('user.profile.email', 'john.doe@example.com'); + $context->setHidden('api.credentials.key', 'secret'); + + $expected = [ + 'user' => [ + 'profile' => [ + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + ], + ], + ]; + + $this->assertSame($expected, $context->getOnly(['user.profile.name', 'user.profile.email', 'user.profile.non_existent_key'])); // non_existent_key should be ignored + } + + public function testGetExceptSingleKey(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->set('username', 'john_doe'); + $context->setHidden('api_key', 'secret'); + + $expected = [ + 'username' => 'john_doe', + ]; + $this->assertSame($expected, $context->getExcept('user_id')); // user_id should be excluded + } + + public function testGetExceptSingleKeyWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->set('user.profile.email', 'john.doe@example.com'); + $context->setHidden('api.credentials.key', 'secret'); + + $expected = [ + 'user' => [ + 'profile' => [ + 'email' => 'john.doe@example.com', + ], + ], + ]; + $this->assertSame($expected, $context->getExcept('user.profile.name')); // user.profile.name should be excluded + } + + public function testGetExceptMultipleKeys(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->set('username', 'john_doe'); + $context->setHidden('api_key', 'secret'); + + $expected = [ + 'username' => 'john_doe', + ]; + $this->assertSame($expected, $context->getExcept(['user_id', 'non_existent_key'])); // user_id should be excluded, non_existent_key should be ignored + } + + public function testGetExceptMultipleKeysWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->set('user.profile.email', 'john.doe@example.com'); + $context->setHidden('api.credentials.key', 'secret'); + + $expected = [ + 'user' => [ + 'profile' => [ + 'email' => 'john.doe@example.com', + ], + ], + ]; + $this->assertSame($expected, $context->getExcept(['user.profile.name', 'non_existent_key'])); // user.profile.name should be excluded, non_existent_key should be ignored + } + + public function testGetAll(): void + { + $context = single_service('context'); + $context->set([ + 'user_id' => 123, + 'username' => 'john_doe', + ]); + + $expected = [ + 'user_id' => 123, + 'username' => 'john_doe', + ]; + + $this->assertSame($expected, $context->getAll()); + } + + public function testGetAllWithDotNotation(): void + { + $context = single_service('context'); + $context->set([ + 'user.profile.name' => 'John Doe', + 'request.corr_id' => 'abc123', + ]); + + $expected = [ + 'user' => [ + 'profile' => [ + 'name' => 'John Doe', + ], + ], + 'request' => [ + 'corr_id' => 'abc123', + ], + ]; + + $this->assertSame($expected, $context->getAll()); + } + + public function testGetHiddenWithDefaultValue(): void + { + $context = single_service('context'); + + $context->setHidden('some_secret_token', '123456abcdefghij'); + + $this->assertSame('123456abcdefghij', $context->getHidden('some_secret_token', 'foo')); // Existing key should return its value, not the default + $this->assertSame('foo', $context->getHidden('api_key', 'foo')); + } + + public function testGetHiddenWithDotNotationAndDefaultValue(): void + { + $context = single_service('context'); + + $context->setHidden('api.credentials.key', 'secret12345'); + + $this->assertSame('secret12345', $context->getHidden('api.credentials.key', 'default')); // Existing key should return its value, not the default + $this->assertSame('default', $context->getHidden('api.credentials.non_existent_key', 'default')); + } + + public function testGetOnlyHiddenSingleKey(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api_key', 'some_secret_api_key_here'); + + $this->assertSame(['api_key' => 'some_secret_api_key_here'], $context->getOnlyHidden('api_key')); + $this->assertSame([], $context->getOnlyHidden('some_token')); + } + + public function testGetOnlyHiddenSingleKeyWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api.credentials.key', 'some_secret_api_key_here'); + + $this->assertSame(['api' => ['credentials' => ['key' => 'some_secret_api_key_here']]], $context->getOnlyHidden('api.credentials.key')); + $this->assertSame([], $context->getOnlyHidden('api.credentials.non_existent_key')); + } + + public function testGetOnlyHiddenMultipleKeys(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api_key', 'secret'); + $context->setHidden('token', 'abc123'); + + $expected = [ + 'api_key' => 'secret', + 'token' => 'abc123', + ]; + $this->assertSame($expected, $context->getOnlyHidden(['api_key', 'token', 'non_existent_key'])); // non_existent_key should be ignored + } + + public function testGetOnlyHiddenMultipleKeysWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api.credentials.key', 'secret'); + $context->setHidden('api.credentials.token', 'abc123'); + + $expected = [ + 'api' => [ + 'credentials' => [ + 'key' => 'secret', + 'token' => 'abc123', + ], + ], + ]; + $this->assertSame($expected, $context->getOnlyHidden(['api.credentials.key', 'api.credentials.token', 'api.credentials.non_existent_key'])); // non_existent_key should be ignored + } + + public function testGetExceptHiddenSingleKey(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('some_sensitive_user_info', 'abcdefghij'); + $context->setHidden('api_key', 'some_secret_api_key_here'); + + $expected = [ + 'some_sensitive_user_info' => 'abcdefghij', + ]; + + $this->assertSame($expected, $context->getExceptHidden('api_key')); + } + + public function testGetExceptHiddenSingleKeyWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api.credentials.key', 'secret'); + $context->setHidden('api.credentials.token', 'abc123'); + + $expected = [ + 'api' => [ + 'credentials' => [ + 'key' => 'secret', + ], + ], + ]; + + $this->assertSame($expected, $context->getExceptHidden('api.credentials.token')); + } + + public function testGetExceptHiddenMultipleKeys(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('token', 'abc123'); + $context->setHidden('api_key', 'secret'); + + $expected = [ + 'token' => 'abc123', + ]; + $this->assertSame($expected, $context->getExceptHidden(['api_key', 'non_existent_key'])); // token should be excluded, non_existent_key should be ignored + } + + public function testGetExceptHiddenMultipleKeysWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('api.credentials.key', 'secret'); + $context->setHidden('api.credentials.token', 'abc123'); + $context->setHidden('api.credentials.session_id', 'xyz789'); + + $expected = [ + 'api' => [ + 'credentials' => [ + 'key' => 'secret', + 'session_id' => 'xyz789', + ], + ], + ]; + $this->assertSame($expected, $context->getExceptHidden(['api.credentials.token', 'non_existent_key'])); // api.credentials.token should be excluded, non_existent_key should be ignored + } + + public function testGetAllHidden(): void + { + $context = single_service('context'); + $context->setHidden([ + 'api_key' => 'secret', + 'token' => 'abc123', + ]); + + $expected = [ + 'api_key' => 'secret', + 'token' => 'abc123', + ]; + + $this->assertSame($expected, $context->getAllHidden()); + } + + public function testGetAllHiddenWithDotNotation(): void + { + $context = single_service('context'); + $context->setHidden([ + 'api.credentials.key' => 'secret', + 'api.credentials.token' => 'abc123', + ]); + + $expected = [ + 'api' => [ + 'credentials' => [ + 'key' => 'secret', + 'token' => 'abc123', + ], + ], + ]; + + $this->assertSame($expected, $context->getAllHidden()); + } + + public function testOverwriteExistingValue(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->set('user_id', 456); // Overwrite existing value + + $this->assertSame(456, $context->get('user_id')); + } + + public function testOverwriteExistingValueWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->set('user.profile.name', 'Something Different'); // Overwrite existing value + + $this->assertSame('Something Different', $context->get('user.profile.name')); + } + + public function testOverwriteExistingHiddenValue(): void + { + $context = single_service('context'); + $context->setHidden('api_key', 'secret'); + $context->setHidden('api_key', 'new_secret'); // Overwrite existing hidden value + + $this->assertSame('new_secret', $context->getHidden('api_key')); + } + + public function testOverwriteExistingHiddenValueWithDotNotation(): void + { + $context = single_service('context'); + $context->setHidden('api.credentials.key', 'secret'); + $context->setHidden('api.credentials.key', 'new_secret'); // Overwrite existing hidden value + + $this->assertSame('new_secret', $context->getHidden('api.credentials.key')); + } + + public function testSetHiddenDoesNotAffectNormalValues(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->setHidden('user_id', 'hidden_value'); + + $this->assertSame(123, $context->get('user_id')); // Normal value should still be retrievable + $this->assertSame('hidden_value', $context->getHidden('user_id')); // Hidden value should be retrievable with getHidden() + } + + public function testSetHiddenWithDotNotationDoesNotAffectNormalValues(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->setHidden('user.profile.name', 'Hidden Name'); + + $this->assertSame('John Doe', $context->get('user.profile.name')); // Normal value should still be retrievable + $this->assertSame('Hidden Name', $context->getHidden('user.profile.name')); // Hidden value should be retrievable with getHidden() + } + + public function testHasKey(): void + { + $context = single_service('context'); + $this->assertFalse($context->has('user_id')); + + $context->set('user_id', 123); + + $this->assertTrue($context->has('user_id')); + } + + public function testHasKeyWithDotNotation(): void + { + $context = single_service('context'); + $this->assertFalse($context->has('user.profile.name')); + + $context->set('user.profile.name', 'John Doe'); + + $this->assertTrue($context->has('user.profile.name')); + } + + public function testHasHiddenKey(): void + { + $context = single_service('context'); + $this->assertFalse($context->hasHidden('api_key')); + + $context->setHidden('api_key', 'secret'); + $this->assertTrue($context->hasHidden('api_key')); + } + + public function testHasHiddenKeyWithDotNotation(): void + { + $context = single_service('context'); + $this->assertFalse($context->hasHidden('api.credentials.key')); + + $context->setHidden('api.credentials.key', 'secret'); + $this->assertTrue($context->hasHidden('api.credentials.key')); + } + + public function testRemoveSingleValue(): void + { + $context = single_service('context'); + $context->set('user_id', 123); + $context->set('username', 'john_doe'); + $context->remove('user_id'); + + $this->assertNull($context->get('user_id')); + $this->assertSame('john_doe', $context->get('username')); // Ensure other values are unaffected + } + + public function testRemoveSingleValueWithDotNotation(): void + { + $context = single_service('context'); + $context->set('user.profile.name', 'John Doe'); + $context->set('user.profile.email', 'john@example.com'); + $context->remove('user.profile.name'); + + $this->assertNull($context->get('user.profile.name')); + $this->assertSame('john@example.com', $context->get('user.profile.email')); // Ensure other values are unaffected + } + + public function testRemoveMultipleValues(): void + { + $context = single_service('context'); + $context->set([ + 'user_id' => 123, + 'username' => 'john_doe', + 'email' => 'john@example.com', + ]); + + $context->remove(['user_id', 'username']); + + $this->assertNull($context->get('user_id')); + $this->assertNull($context->get('username')); + $this->assertSame('john@example.com', $context->get('email')); // Ensure other values are unaffected + } + + public function testRemoveMultipleValuesWithDotNotation(): void + { + $context = single_service('context'); + $context->set([ + 'user.id' => 123, + 'request.corr_id' => '12345', + 'user.email' => 'john@example.com', + ]); + + $context->remove(['user.id', 'request.corr_id']); + + $this->assertNull($context->get('user.id')); + $this->assertNull($context->get('request.corr_id')); + $this->assertSame('john@example.com', $context->get('user.email')); + } + + public function testRemoveHiddenValue(): void + { + $context = single_service('context'); + $context->setHidden('api_key', 'secret'); + $context->setHidden('token', 'abc123'); + + $context->removeHidden('api_key'); + $this->assertNull($context->getHidden('api_key')); + $this->assertSame('abc123', $context->getHidden('token')); // Ensure other hidden values are unaffected + } + + public function testRemoveHiddenValueWithDotNotation(): void + { + $context = single_service('context'); + $context->setHidden('credentials.api_key', 'secret'); + $context->setHidden('credentials.token', 'abc123'); + + $context->removeHidden('credentials.api_key'); + $this->assertNull($context->getHidden('credentials.api_key')); + $this->assertSame('abc123', $context->getHidden('credentials.token')); // Ensure other hidden values are unaffected + } + + public function testRemoveMultipleHiddenValues(): void + { + $context = single_service('context'); + $context->setHidden([ + 'api_key' => 'secret', + 'token' => 'abc123', + 'session_id' => 'xyz789', + ]); + + $context->removeHidden(['api_key', 'token']); + + $this->assertNull($context->getHidden('api_key')); + $this->assertNull($context->getHidden('token')); + $this->assertSame('xyz789', $context->getHidden('session_id')); // Ensure other hidden values are unaffected + } + + public function testRemoveMultipleHiddenValuesWithDotNotation(): void + { + $context = single_service('context'); + $context->setHidden([ + 'credentials.api_key' => 'secret', + 'credentials.token' => 'abc123', + 'session_id' => 'xyz789', + ]); + + $context->removeHidden(['credentials.api_key', 'credentials.token']); + + $this->assertNull($context->getHidden('credentials.api_key')); + $this->assertNull($context->getHidden('credentials.token')); + $this->assertSame('xyz789', $context->getHidden('session_id')); // Ensure other hidden values are unaffected + } + + public function testPrintRDoesNotExposeHiddenValues(): void + { + $context = new Context(); + $context->set('user_id', 123); + $context->setHidden('credentials.api_key', 'secret'); + + $output = print_r($context, true); + + $this->assertStringContainsString('user_id', $output); + $this->assertStringNotContainsString('secret', $output); + $this->assertStringContainsString('SensitiveParameterValue', $output); + } + + public function testCloneDoesNotCopyHiddenValues(): void + { + $context = new Context(); + $context->set('user_id', 123); + $context->setHidden('credentials.api_key', 'secret'); + + $clonedContext = clone $context; + + $this->assertSame(123, $clonedContext->get('user_id')); // Normal value should be copied + $this->assertNull($clonedContext->getHidden('credentials.api_key')); // Hidden value should not be copied + } + + public function testSerializationDoesNotIncludeHiddenValues(): void + { + $context = new Context(); + $context->set('user_id', 123); + $context->setHidden('credentials.api_key', 'secret'); + + $serialized = serialize($context); + + $this->assertStringContainsString('user_id', $serialized); + $this->assertStringNotContainsString('secret', $serialized); + + $unserializedContext = unserialize($serialized); + + $this->assertSame(123, $unserializedContext->get('user_id')); // Normal value should be preserved + $this->assertNull($unserializedContext->getHidden('credentials.api_key')); // Hidden value should not be preserved + } +} diff --git a/tests/system/ControllerTest.php b/tests/system/ControllerTest.php index 2ca26f9aa15c..6040403e77f5 100644 --- a/tests/system/ControllerTest.php +++ b/tests/system/ControllerTest.php @@ -59,7 +59,7 @@ protected function setUp(): void $config = new App(); $this->request = new IncomingRequest($config, new SiteURI($config), null, new UserAgent()); - $this->response = new Response($config); + $this->response = new Response(); $this->logger = service('logger'); } diff --git a/tests/system/DataConverter/DataConverterTest.php b/tests/system/DataConverter/DataConverterTest.php index 6686618a181b..b90d891a24fc 100644 --- a/tests/system/DataConverter/DataConverterTest.php +++ b/tests/system/DataConverter/DataConverterTest.php @@ -197,6 +197,62 @@ public static function provideConvertDataFromDB(): iterable 'temp' => 15.9, ], ], + 'float precise' => [ + [ + 'id' => 'int', + 'temp' => 'float[2]', + ], + [ + 'id' => '1', + 'temp' => '15.98765', + ], + [ + 'id' => 1, + 'temp' => 15.99, + ], + ], + 'float precise-down' => [ + [ + 'id' => 'int', + 'temp' => 'float[2,down]', + ], + [ + 'id' => '1', + 'temp' => '1.235', + ], + [ + 'id' => 1, + 'temp' => 1.23, + ], + ], + 'float precise-even' => [ + [ + 'id' => 'int', + 'temp' => 'float[2,even]', + ], + [ + 'id' => '1', + 'temp' => '20.005', + ], + [ + 'id' => 1, + 'temp' => 20.00, + ], + ], + 'float precise-odd' => [ + [ + 'id' => 'int', + 'temp' => 'float[2,odd]', + ], + [ + 'id' => '1', + 'temp' => '1.255', + ], + [ + 'id' => 1, + 'temp' => 1.25, + ], + ], 'enum string-backed' => [ [ 'id' => 'int', @@ -988,4 +1044,20 @@ public static function provideEnumExceptions(): iterable ], ]; } + + public function testInvalidFloatRoundingMode(): void + { + $this->expectException(CastException::class); + $this->expectExceptionMessage('Invalid rounding mode "wrong" for float casting.'); + + $converter = $this->createDataConverter([ + 'id' => 'int', + 'temp' => 'float[2,wrong]', + ]); + + $converter->fromDataSource([ + 'id' => '123456', + 'temp' => 123.456, + ]); + } } diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index 4d6d1f76173d..a679fd5a54f2 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -14,10 +14,13 @@ namespace CodeIgniter\Database; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; +use ErrorException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Mock\MockPreparedQuery; use Throwable; use TypeError; @@ -42,7 +45,6 @@ final class BaseConnectionTest extends CIUnitTestCase 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, 'failover' => [], 'dateFormat' => [ 'date' => 'Y-m-d', @@ -65,7 +67,6 @@ final class BaseConnectionTest extends CIUnitTestCase 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, 'failover' => [], ]; @@ -85,7 +86,6 @@ public function testSavesConfigOptions(): void $this->assertSame('', $db->swapPre); $this->assertFalse($db->encrypt); $this->assertFalse($db->compress); - $this->assertTrue($db->strictOn); $this->assertSame([], $db->failover); $this->assertSame([ 'date' => 'Y-m-d', @@ -471,6 +471,484 @@ public static function provideEscapeIdentifier(): iterable ]; } + public function testConvertTimezoneToOffsetWithOffset(): void + { + $db = new MockConnection($this->options); + + // Offset strings should be returned as-is + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('+05:30'); + $this->assertSame('+05:30', $result); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('-08:00'); + $this->assertSame('-08:00', $result); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('+00:00'); + $this->assertSame('+00:00', $result); + } + + public function testConvertTimezoneToOffsetWithNamedTimezone(): void + { + $db = new MockConnection($this->options); + + // UTC should always be +00:00 + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('UTC'); + $this->assertSame('+00:00', $result); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('America/New_York'); + $this->assertContains($result, ['-05:00', '-04:00']); // EST/EDT + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Europe/Paris'); + $this->assertContains($result, ['+01:00', '+02:00']); // CET/CEST + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Asia/Tokyo'); + $this->assertSame('+09:00', $result); // JST (no DST) + } + + public function testConvertTimezoneToOffsetWithInvalidTimezone(): void + { + $db = new MockConnection($this->options); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Invalid/Timezone'); + $this->assertSame('+00:00', $result); + $this->assertLogged('error', "Invalid timezone 'Invalid/Timezone'. Falling back to UTC. DateTimeZone::__construct(): Unknown or bad timezone (Invalid/Timezone)."); + } + + public function testGetSessionTimezoneWithFalse(): void + { + $options = $this->options; + $options['timezone'] = false; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertNull($result); + } + + public function testGetSessionTimezoneWithTrue(): void + { + $options = $this->options; + $options['timezone'] = true; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertSame('+00:00', $result); // UTC = +00:00 + } + + public function testGetSessionTimezoneWithSpecificOffset(): void + { + $options = $this->options; + $options['timezone'] = '+05:30'; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertSame('+05:30', $result); + } + + public function testGetSessionTimezoneWithSpecificNamedTimezone(): void + { + $options = $this->options; + $options['timezone'] = 'America/Chicago'; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertContains($result, ['-06:00', '-05:00']); + } + + public function testGetSessionTimezoneWithoutTimezoneKey(): void + { + $db = new MockConnection($this->options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertNull($result); + } + + public function testInTransactionReflectsManagedTransactionState(): void + { + $db = new MockConnection($this->options); + + $this->assertFalse($db->inTransaction()); + + $this->assertTrue($db->transBegin()); + $this->assertTrue($db->inTransaction()); + + $this->assertTrue($db->transBegin()); + $this->assertTrue($db->inTransaction()); + + $this->assertTrue($db->transCommit()); + $this->assertTrue($db->inTransaction()); + + $this->assertTrue($db->transCommit()); + $this->assertFalse($db->inTransaction()); + } + + public function testInTransactionReturnsFalseWhenTransactionsAreDisabled(): void + { + $db = new MockConnection($this->options); + + $db->transOff(); + + $this->assertFalse($db->transBegin()); + $this->assertFalse($db->inTransaction()); + } + + public function testInTransactionReturnsTrueInsideTransactionCallback(): void + { + $db = new MockConnection($this->options); + $state = null; + + $result = $db->transaction(static function (BaseConnection $connection) use (&$state): string { + $state = $connection->inTransaction(); + + return 'done'; + }); + + $this->assertSame('done', $result); + $this->assertTrue($state); + $this->assertFalse($db->inTransaction()); + } + + public function testInTransactionReturnsFalseInsideTransactionCallbacks(): void + { + $db = new MockConnection($this->options); + $commitState = null; + $rollbackState = null; + + $this->assertTrue($db->transBegin()); + $db->afterCommit(static function () use ($db, &$commitState): void { + $commitState = $db->inTransaction(); + }); + $this->assertTrue($db->transCommit()); + $this->assertFalse($commitState); + + $this->assertTrue($db->transBegin()); + $db->afterRollback(static function () use ($db, &$rollbackState): void { + $rollbackState = $db->inTransaction(); + }); + $this->assertTrue($db->transRollback()); + $this->assertFalse($rollbackState); + } + + public function testAfterCommitCallbacksRemainQueuedWhenDriverCommitFails(): void + { + $callbacks = []; + + $db = new class ($this->options) extends MockConnection { + public int $commitAttempts = 0; + + protected function _transCommit(): bool + { + $this->commitAttempts++; + + return $this->commitAttempts > 1; + } + }; + + $this->assertTrue($db->transBegin()); + $db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->assertFalse($db->transCommit()); + $this->assertSame([], $callbacks); + $this->assertSame(1, $db->transDepth); + $this->assertTrue($db->inTransaction()); + + $this->assertTrue($db->transCommit()); + $this->assertSame(['committed'], $callbacks); + $this->assertSame(0, $db->transDepth); + $this->assertFalse($db->inTransaction()); + + $this->assertTrue($db->transBegin()); + $this->assertTrue($db->transCommit()); + $this->assertSame(['committed'], $callbacks); + } + + public function testAfterRollbackCallbacksRemainQueuedWhenDriverRollbackFails(): void + { + $callbacks = []; + + $db = new class ($this->options) extends MockConnection { + public int $rollbackAttempts = 0; + + protected function _transRollback(): bool + { + $this->rollbackAttempts++; + + return $this->rollbackAttempts > 1; + } + }; + + $this->assertTrue($db->transBegin()); + $db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertFalse($db->transRollback()); + $this->assertSame([], $callbacks); + $this->assertSame(1, $db->transDepth); + $this->assertTrue($db->inTransaction()); + + $this->assertTrue($db->transRollback()); + $this->assertSame(['rolled back'], $callbacks); + $this->assertSame(0, $db->transDepth); + $this->assertFalse($db->inTransaction()); + + $this->assertTrue($db->transBegin()); + $this->assertTrue($db->transRollback()); + $this->assertSame(['rolled back'], $callbacks); + } + + public function testTransactionReturnsFalseWhenTransactionCannotBegin(): void + { + $callbackRan = false; + + $db = new class ($this->options) extends MockConnection { + protected function _transBegin(): bool + { + return false; + } + }; + + $result = $db->transaction(static function () use (&$callbackRan): void { + $callbackRan = true; + }); + + $this->assertFalse($result); + $this->assertFalse($callbackRan); + } + + public function testTransactionRejectsInvalidAttempts(): void + { + $db = new MockConnection($this->options); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Transaction attempts must be a positive integer.'); + + $db->transaction(static function (): void {}, attempts: self::invalidTransactionAttempts()); + } + + public function testTransactionRetriesSuppressedRetryableQueryFailure(): void + { + $db = new class ($this->options) extends MockConnection { + public int $queries = 0; + + public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = ''): bool + { + $this->queries++; + + if ($this->queries === 1) { + $exception = $this->createDatabaseException('Deadlock found when trying to get lock.', 1213); + + $this->setLastException($exception); + $this->handleTransStatus($exception); + + return false; + } + + return true; + } + + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 1213; + } + }; + + $callbackRuns = 0; + + $result = $db->transaction(static function (BaseConnection $connection) use (&$callbackRuns): string|false { + $callbackRuns++; + $result = $connection->query('INSERT INTO job (name) VALUES (\'Retried Job\')'); + + return $result === true ? 'committed' : false; + }, attempts: 2); + + $this->assertSame('committed', $result); + $this->assertSame(2, $callbackRuns); + $this->assertSame(2, $db->queries); + $this->assertNotInstanceOf(DatabaseException::class, $db->getLastException()); + } + + public function testTransactionRetriesRetryableQueryFailureWhenTransExceptionRollsBack(): void + { + $db = new class ($this->options) extends MockConnection { + public int $queries = 0; + + public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = '') + { + return BaseConnection::query($sql, $binds, $setEscapeFlags, $queryClass); + } + + protected function execute(string $sql): object + { + $this->queries++; + + if ($this->queries === 1) { + throw $this->createDatabaseException('Deadlock found when trying to get lock.', 1213); + } + + return (object) []; + } + + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 1213; + } + }; + $db->transException(true); + + $callbackRuns = 0; + + $result = $db->transaction(static function (BaseConnection $connection) use (&$callbackRuns): string { + $callbackRuns++; + $connection->query('INSERT INTO job (name) VALUES (\'Retried Job\')'); + + return 'committed'; + }, attempts: 2); + + $this->assertSame('committed', $result); + $this->assertSame(2, $callbackRuns); + $this->assertSame(2, $db->queries); + $this->assertSame(0, $db->transDepth); + } + + public function testTransactionDoesNotRetrySuppressedNonRetryableQueryFailure(): void + { + $db = new class ($this->options) extends MockConnection { + public int $queries = 0; + + public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = ''): bool + { + $this->queries++; + $this->handleTransStatus($this->createDatabaseException('Syntax error.', 1064)); + + return false; + } + }; + + $callbackRuns = 0; + + $result = $db->transaction(static function (BaseConnection $connection) use (&$callbackRuns): string|false { + $callbackRuns++; + $result = $connection->query('INSERT INTO job (name) VALUES (\'Failed Job\')'); + + return $result === true ? 'not returned' : false; + }, attempts: 2); + + $this->assertFalse($result); + $this->assertSame(1, $callbackRuns); + $this->assertSame(1, $db->queries); + } + + public function testTransactionRetriesSuppressedRetryablePreparedQueryFailure(): void + { + $db = new class (array_merge($this->options, ['DBDebug' => false])) extends MockConnection { + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 1213; + } + }; + + $callbackRuns = 0; + $preparedQuery = new MockPreparedQuery($db); + $preparedQuery->prepare('INSERT INTO job (name) VALUES (?)'); + + $result = $db->transaction(static function () use (&$callbackRuns, $preparedQuery): bool { + $callbackRuns++; + $preparedQuery->thrownException = $callbackRuns === 1 + ? new ErrorException('Deadlock found when trying to get lock.', 1213) + : null; + + return $preparedQuery->execute('Retried Job'); + }, attempts: 2); + + $this->assertTrue($result); + $this->assertSame(2, $callbackRuns); + } + + public function testTransactionDoesNotRetryCallbackExceptionWhenRollbackFails(): void + { + $db = new class ($this->options) extends MockConnection { + protected function _transRollback(): bool + { + return false; + } + + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 1213; + } + }; + + $callbackRuns = 0; + + try { + $db->transaction(static function (BaseConnection $connection) use (&$callbackRuns): void { + $callbackRuns++; + + throw $connection->createDatabaseException('Deadlock found when trying to get lock.', 1213); + }, attempts: 2); + $this->fail('Expected retryable transaction exception.'); + } catch (DatabaseException $e) { + $this->assertSame('Deadlock found when trying to get lock.', $e->getMessage()); + } + + $this->assertSame(1, $callbackRuns); + $this->assertSame(1, $db->transDepth); + } + + public function testTransactionDoesNotRetrySuppressedQueryFailureWhenRollbackFails(): void + { + $db = new class ($this->options) extends MockConnection { + public int $queries = 0; + + public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = ''): bool + { + $this->queries++; + $this->handleTransStatus($this->createDatabaseException('Deadlock found when trying to get lock.', 1213)); + + return false; + } + + protected function _transRollback(): bool + { + return false; + } + + protected function isRetryableTransactionErrorCode(int|string $code): bool + { + return $code === 1213; + } + }; + + $callbackRuns = 0; + + $result = $db->transaction(static function (BaseConnection $connection) use (&$callbackRuns): bool { + $callbackRuns++; + + return $connection->query('INSERT INTO job (name) VALUES (\'Failed Job\')'); + }, attempts: 2); + + $this->assertFalse($result); + $this->assertSame(1, $callbackRuns); + $this->assertSame(1, $db->queries); + $this->assertSame(1, $db->transDepth); + } + + public function testTransactionRunsCallbackWhenTransactionsAreDisabled(): void + { + $db = new MockConnection($this->options); + $db->transOff(); + + $result = $db->transaction(static function (BaseConnection $connection): string { + $connection->afterCommit(static function (): void {}); + + return 'not wrapped'; + }); + + $this->assertSame('not wrapped', $result); + $this->assertSame(0, $db->transDepth); + } + public function testCallFunctionDoesNotDoublePrefixAlreadyPrefixedName(): void { $db = new class ($this->options) extends MockConnection { @@ -494,4 +972,9 @@ protected function getDriverFunctionPrefix(): string $this->assertTrue($db->callFunction('contains', 'CodeIgniter', 'Ignite')); } + + private static function invalidTransactionAttempts(): int + { + return 0; + } } diff --git a/tests/system/Database/BaseQueryTest.php b/tests/system/Database/BaseQueryTest.php index c5265b85dfa7..2eef552ecdc1 100644 --- a/tests/system/Database/BaseQueryTest.php +++ b/tests/system/Database/BaseQueryTest.php @@ -17,6 +17,8 @@ use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; /** * @internal @@ -242,6 +244,17 @@ public function testBindingAutoEscapesParameters(): void $this->assertSame($expected, $query->getQuery()); } + public function testBindingBackedEnum(): void + { + $query = new Query($this->db); + + $query->setQuery('SELECT * FROM users WHERE status = ? AND role = ?', [StatusEnum::ACTIVE, RoleEnum::ADMIN]); + + $expected = "SELECT * FROM users WHERE status = 'active' AND role = 2"; + + $this->assertSame($expected, $query->getQuery()); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/5114 */ diff --git a/tests/system/Database/Builder/AliasTest.php b/tests/system/Database/Builder/AliasTest.php index a5e3dd0ad29e..571aa1df042a 100644 --- a/tests/system/Database/Builder/AliasTest.php +++ b/tests/system/Database/Builder/AliasTest.php @@ -39,7 +39,7 @@ public function testAlias(): void $expectedSQL = 'SELECT * FROM "jobs" "j"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testTableName(): void @@ -49,7 +49,7 @@ public function testTableName(): void $expectedSQL = 'SELECT * FROM "jobs" "j"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testAliasSupportsArrayOfNames(): void @@ -58,7 +58,7 @@ public function testAliasSupportsArrayOfNames(): void $expectedSQL = 'SELECT * FROM "jobs" "j", "users" "u"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testAliasSupportsStringOfNames(): void @@ -67,7 +67,7 @@ public function testAliasSupportsStringOfNames(): void $expectedSQL = 'SELECT * FROM "jobs" "j", "users" "u"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -82,7 +82,7 @@ public function testAliasLeftJoinWithShortTableName(): void $expectedSQL = 'SELECT * FROM "db_jobs" LEFT JOIN "db_users" as "u" ON "u"."id" = "db_jobs"."id"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -97,7 +97,7 @@ public function testAliasLeftJoinWithLongTableName(): void $expectedSQL = 'SELECT * FROM "db_jobs" LEFT JOIN "db_users" as "u" ON "db_users"."id" = "db_jobs"."id"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -113,6 +113,6 @@ public function testAliasSimpleLikeWithDBPrefix(): void $expectedSQL = <<<'SQL' SELECT * FROM "db_jobs" "j" WHERE "j"."name" LIKE '%veloper%' ESCAPE '!' SQL; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/CountTest.php b/tests/system/Database/Builder/CountTest.php index 8d129efb5c31..8488cd8b03bb 100644 --- a/tests/system/Database/Builder/CountTest.php +++ b/tests/system/Database/Builder/CountTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\Group; @@ -52,7 +53,63 @@ public function testCountAllResults(): void $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id:'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSameSql($expectedSQL, $answer); + } + + public function testCountAllResultsDoesNotUseLockForUpdate(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false); + + $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id:'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR UPDATE', $builder->getCompiledSelect(false)); + } + + public function testCountAllResultsDoesNotUseSharedLock(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->sharedLock()->countAllResults(false); + + $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id:'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 FOR SHARE', $builder->getCompiledSelect(false)); + } + + public function testCountAllResultsWithSQLSRVDoesNotUseLockForUpdate(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->lockForUpdate()->countAllResults(false); + + $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "test"."dbo"."jobs" WHERE "id" > :id:'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false)); + } + + public function testCountAllResultsWithSQLSRVDoesNotUseSharedLock(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->sharedLock()->countAllResults(false); + + $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "test"."dbo"."jobs" WHERE "id" > :id:'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (HOLDLOCK, ROWLOCK) WHERE "id" > 3', $builder->getCompiledSelect(false)); } public function testCountAllResultsWithGroupBy(): void @@ -65,7 +122,7 @@ public function testCountAllResultsWithGroupBy(): void $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM ( SELECT * FROM "jobs" WHERE "id" > :id: GROUP BY "id" ) CI_count_all_results'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSameSql($expectedSQL, $answer); } /** @@ -81,11 +138,11 @@ public function testCountAllResultsWithGroupByAndPrefix(): void $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM ( SELECT "ci_jobs".* FROM "ci_jobs" WHERE "id" > :id: GROUP BY "id" ) CI_count_all_results'; $answer1 = $builder->countAllResults(false); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer1)); + $this->assertSameSql($expectedSQL, $answer1); // We run the query one more time to make sure the DBPrefix is added only once $answer2 = $builder->countAllResults(false); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer2)); + $this->assertSameSql($expectedSQL, $answer2); } public function testCountAllResultsWithGroupByAndHaving(): void @@ -99,7 +156,7 @@ public function testCountAllResultsWithGroupByAndHaving(): void $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM ( SELECT * FROM "jobs" WHERE "id" > :id: GROUP BY "id" HAVING 1 = 1 ) CI_count_all_results'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSameSql($expectedSQL, $answer); } public function testCountAllResultsWithHavingOnly(): void @@ -112,6 +169,6 @@ public function testCountAllResultsWithHavingOnly(): void $expectedSQL = 'SELECT COUNT(*) AS "numrows" FROM "jobs" WHERE "id" > :id: HAVING 1 = 1'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSameSql($expectedSQL, $answer); } } diff --git a/tests/system/Database/Builder/DeleteTest.php b/tests/system/Database/Builder/DeleteTest.php index 53b66eed805b..59c776b90804 100644 --- a/tests/system/Database/Builder/DeleteTest.php +++ b/tests/system/Database/Builder/DeleteTest.php @@ -46,7 +46,7 @@ public function testDelete(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSameSql($expectedSQL, $answer); $this->assertSame($expectedBinds, $builder->getBinds()); } diff --git a/tests/system/Database/Builder/DistinctTest.php b/tests/system/Database/Builder/DistinctTest.php index 5a5712db2c0e..8d8bc03130ac 100644 --- a/tests/system/Database/Builder/DistinctTest.php +++ b/tests/system/Database/Builder/DistinctTest.php @@ -41,6 +41,6 @@ public function testDelete(): void $expectedSQL = 'SELECT DISTINCT "country" FROM "user"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/EmptyTest.php b/tests/system/Database/Builder/EmptyTest.php index 959bfbc81c56..c5526a0730a6 100644 --- a/tests/system/Database/Builder/EmptyTest.php +++ b/tests/system/Database/Builder/EmptyTest.php @@ -41,6 +41,6 @@ public function testEmptyWithNoTable(): void $expectedSQL = 'DELETE FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSameSql($expectedSQL, $answer); } } diff --git a/tests/system/Database/Builder/ExistsTest.php b/tests/system/Database/Builder/ExistsTest.php new file mode 100644 index 000000000000..8a6b033b6228 --- /dev/null +++ b/tests/system/Database/Builder/ExistsTest.php @@ -0,0 +1,245 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Builder; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use Config\Feature; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ExistsTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $this->db = new MockConnection([]); + } + + public function testExistsReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->exists(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + } + + public function testDoesntExistReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->doesntExist(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + } + + public function testExistsDoesNotUseOrderByOrLockForUpdate(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->lockForUpdate() + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 ORDER BY "id" DESC FOR UPDATE', $builder->getCompiledSelect(false)); + } + + public function testExistsDoesNotUseOrderByOrSharedLock(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->sharedLock() + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 ORDER BY "id" DESC FOR SHARE', $builder->getCompiledSelect(false)); + } + + public function testExistsWithSQLSRVDoesNotUseOrderByOrLockForUpdate(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->lockForUpdate() + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "test"."dbo"."jobs" WHERE "id" > :id: ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY '; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3 ORDER BY "id" DESC', $builder->getCompiledSelect(false)); + } + + public function testExistsWithSQLSRVDoesNotUseOrderByOrSharedLock(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->sharedLock() + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "test"."dbo"."jobs" WHERE "id" > :id: ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY '; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "test"."dbo"."jobs" WITH (HOLDLOCK, ROWLOCK) WHERE "id" > 3 ORDER BY "id" DESC', $builder->getCompiledSelect(false)); + } + + public function testExistsHonorsExistingLimitAndOffset(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->limit(10, 20) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT * FROM "jobs" WHERE "id" > :id: LIMIT 20, 10 ) CI_exists LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3 LIMIT 20, 10', $builder->getCompiledSelect(false)); + } + + public function testExistsHonorsLimitZero(): void + { + $config = config(Feature::class); + $limitZeroAsAll = $config->limitZeroAsAll; + $config->limitZeroAsAll = false; + + try { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->limit(0) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 0'; + + $this->assertSameSql($expectedSQL, $answer); + } finally { + $config->limitZeroAsAll = $limitZeroAsAll; + } + } + + public function testExistsWithGroupByAndHaving(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->selectCount('id', 'total') + ->where('id >', 3) + ->groupBy('id') + ->having('total >', 1) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > :id: GROUP BY "id" HAVING "total" > :total: ) CI_exists LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > 3 GROUP BY "id" HAVING "total" > 1', $builder->getCompiledSelect(false)); + } + + public function testExistsWithAggregateSelection(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->selectCount('id', 'total') + ->where('id >', 3) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > :id: ) CI_exists LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > 3', $builder->getCompiledSelect(false)); + } + + public function testExistsWithUnion(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->union($this->db->table('jobs'))->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1" ) CI_exists LIMIT 1'; + + $this->assertSameSql($expectedSQL, $answer); + $this->assertSameSql('SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1"', $builder->getCompiledSelect(false)); + } + + public function testExistsResetsByDefault(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->exists(); + + $this->assertSameSql('SELECT * FROM "jobs"', $builder->getCompiledSelect(false)); + $this->assertSame([], $builder->getBinds()); + } + + public function testExistsHonorsResetFalse(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->exists(false); + + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3', $builder->getCompiledSelect(false)); + $this->assertSame([ + 'id' => [ + 3, + true, + ], + ], $builder->getBinds()); + } + + public function testExistsMethodsReturnFalseWhenQueryFails(): void + { + $db = new MockConnection([]); + $db->shouldReturn('execute', false); + + $this->assertFalse((new BaseBuilder('jobs', $db))->exists()); + $this->assertFalse((new BaseBuilder('jobs', $db))->doesntExist()); + } +} diff --git a/tests/system/Database/Builder/ExplainTest.php b/tests/system/Database/Builder/ExplainTest.php new file mode 100644 index 000000000000..350797a2f3ba --- /dev/null +++ b/tests/system/Database/Builder/ExplainTest.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\Database\Builder; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\OCI8\Builder as OCI8Builder; +use CodeIgniter\Database\SQLite3\Builder as SQLite3Builder; +use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ExplainTest extends CIUnitTestCase +{ + protected $db; + + protected function setUp(): void + { + parent::setUp(); + + $this->db = new MockConnection([]); + } + + public function testExplainReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->explain(false); + + $expectedSQL = 'EXPLAIN SELECT * FROM "jobs" WHERE "id" > 3'; + + $this->assertSameSql($expectedSQL, $answer); + } + + public function testSQLiteExplainUsesQueryPlanInTestMode(): void + { + $builder = new SQLite3Builder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->explain(false); + + $expectedSQL = 'EXPLAIN QUERY PLAN SELECT * FROM "jobs" WHERE "id" > 3'; + + $this->assertSameSql($expectedSQL, $answer); + } + + public function testExplainResetsByDefault(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->explain(); + + $this->assertSameSql('SELECT * FROM "jobs"', $builder->getCompiledSelect(false)); + $this->assertSame([], $builder->getBinds()); + } + + public function testExplainHonorsResetFalse(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->explain(false); + + $this->assertSameSql('SELECT * FROM "jobs" WHERE "id" > 3', $builder->getCompiledSelect(false)); + $this->assertSame([ + 'id' => [ + 3, + true, + ], + ], $builder->getBinds()); + } + + public function testExplainReturnsFalseWhenQueryFails(): void + { + $db = new MockConnection([]); + $db->shouldReturn('execute', false); + + $builder = new BaseBuilder('jobs', $db); + + $this->assertFalse($builder->where('id >', 3)->explain()); + $this->assertSameSql('SELECT * FROM "jobs"', $builder->getCompiledSelect(false)); + $this->assertSame([], $builder->getBinds()); + } + + public function testSQLSRVExplainIsNotSupported(): void + { + $builder = new SQLSRVBuilder('jobs', new MockConnection([ + 'DBDriver' => 'SQLSRV', + 'database' => 'test', + 'schema' => 'dbo', + ])); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support explain().'); + + $builder->explain(); + } + + public function testSQLSRVExplainChecksSupportBeforeCompilingSelect(): void + { + $db = new MockConnection([ + 'DBDriver' => 'SQLSRV', + 'database' => 'test', + 'schema' => 'dbo', + ]); + + $builder = new SQLSRVBuilder('jobs', $db); + $builder->union(new SQLSRVBuilder('jobs', $db))->lockForUpdate(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support explain().'); + + $builder->explain(); + } + + public function testOCI8ExplainIsNotSupported(): void + { + $builder = new OCI8Builder('jobs', new MockConnection(['DBDriver' => 'OCI8'])); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support explain().'); + + $builder->explain(); + } +} diff --git a/tests/system/Database/Builder/FromTest.php b/tests/system/Database/Builder/FromTest.php index 99bacd97fe18..32308fbce277 100644 --- a/tests/system/Database/Builder/FromTest.php +++ b/tests/system/Database/Builder/FromTest.php @@ -42,7 +42,7 @@ public function testSimpleFrom(): void $expectedSQL = 'SELECT * FROM "user", "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testFromThatOverwrites(): void @@ -53,7 +53,7 @@ public function testFromThatOverwrites(): void $expectedSQL = 'SELECT * FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testFromWithMultipleTables(): void @@ -64,7 +64,7 @@ public function testFromWithMultipleTables(): void $expectedSQL = 'SELECT * FROM "user", "jobs", "roles"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testFromWithMultipleTablesAsString(): void @@ -75,7 +75,7 @@ public function testFromWithMultipleTablesAsString(): void $expectedSQL = 'SELECT * FROM "user", "jobs", "roles"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testFromReset(): void @@ -86,23 +86,23 @@ public function testFromReset(): void $expectedSQL = 'SELECT * FROM "user", "jobs", "roles"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedSQL = 'SELECT * FROM "user"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedSQL = 'SELECT *'; $builder->from(null, true); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedSQL = 'SELECT * FROM "jobs"'; $builder->from('jobs'); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testFromSubquery(): void @@ -111,19 +111,19 @@ public function testFromSubquery(): void $subquery = new BaseBuilder('users', $this->db); $builder = $this->db->newQuery()->fromSubquery($subquery, 'alias'); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedSQL = 'SELECT * FROM (SELECT "id", "name" FROM "users") "users_1"'; $subquery = (new BaseBuilder('users', $this->db))->select('id, name'); $builder = $this->db->newQuery()->fromSubquery($subquery, 'users_1'); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedSQL = 'SELECT * FROM (SELECT * FROM "users") "alias", "some_table"'; $subquery = new BaseBuilder('users', $this->db); $builder = $this->db->newQuery()->fromSubquery($subquery, 'alias')->from('some_table'); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testFromWithMultipleTablesAsStringWithSQLSRV(): void @@ -136,7 +136,7 @@ public function testFromWithMultipleTablesAsStringWithSQLSRV(): void $expectedSQL = 'SELECT * FROM "test"."dbo"."user", "test"."dbo"."jobs", "test"."dbo"."roles"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testFromSubqueryWithSQLSRV(): void @@ -151,7 +151,7 @@ public function testFromSubqueryWithSQLSRV(): void $expectedSQL = 'SELECT * FROM "test"."dbo"."jobs", (SELECT * FROM "test"."dbo"."users") "users_1"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -165,7 +165,7 @@ public function testConstructorWithMultipleSegmentTableWithSQLSRV(): void $expectedSQL = 'SELECT * FROM "database"."dbo"."table"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -179,6 +179,6 @@ public function testConstructorWithMultipleSegmentTableWithoutDatabaseWithSQLSRV $expectedSQL = 'SELECT * FROM "test"."dbo"."table"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/GetTest.php b/tests/system/Database/Builder/GetTest.php index 2447511c89ca..11b57525e30c 100644 --- a/tests/system/Database/Builder/GetTest.php +++ b/tests/system/Database/Builder/GetTest.php @@ -39,7 +39,7 @@ public function testGet(): void $expectedSQL = 'SELECT * FROM "users"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** diff --git a/tests/system/Database/Builder/GroupTest.php b/tests/system/Database/Builder/GroupTest.php index d03937ca6b3f..3600937551f5 100644 --- a/tests/system/Database/Builder/GroupTest.php +++ b/tests/system/Database/Builder/GroupTest.php @@ -14,8 +14,10 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -41,7 +43,7 @@ public function testGroupBy(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingBy(): void @@ -54,7 +56,7 @@ public function testHavingBy(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) > 2'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingBy(): void @@ -68,7 +70,135 @@ public function testOrHavingBy(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" > 3 OR SUM(id) > 2'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + #[DataProvider('provideHavingBetweenMethods')] + public function testHavingBetweenMethods(string $method, string $sql): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->{$method}('total', [10, 20]); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "total" ' . $sql . ' 10 AND 20'; + $expectedBinds = [ + 'total' => [ + 10, + true, + ], + 'total.1' => [ + 20, + true, + ], + ]; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @return iterable + */ + public static function provideHavingBetweenMethods(): iterable + { + return [ + 'between' => ['havingBetween', 'BETWEEN'], + 'not between' => ['havingNotBetween', 'NOT BETWEEN'], + ]; + } + + #[DataProvider('provideOrHavingBetweenMethods')] + public function testOrHavingBetweenMethods(string $method, string $sql): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->having('active', 1) + ->{$method}('total', [10, 20]); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "active" = 1 OR "total" ' . $sql . ' 10 AND 20'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + /** + * @return iterable + */ + public static function provideOrHavingBetweenMethods(): iterable + { + return [ + 'or between' => ['orHavingBetween', 'BETWEEN'], + 'or not between' => ['orHavingNotBetween', 'NOT BETWEEN'], + ]; + } + + public function testHavingBetweenWithGroupedConditions(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->havingGroupStart() + ->havingBetween('total', [10, 20]) + ->orHavingNotBetween('score', [30, 40]) + ->havingGroupEnd() + ->having('active', 1); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING ( "total" BETWEEN 10 AND 20 OR "score" NOT BETWEEN 30 AND 40 ) AND "active" = 1'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + public function testHavingBetweenNoEscape(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->havingBetween('SUM(id)', [10, 20], escape: false); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) BETWEEN 10 AND 20'; + $expectedBinds = [ + 'SUM(id)' => [ + 10, + false, + ], + 'SUM(id).1' => [ + 20, + false, + ], + ]; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @param mixed $values + */ + #[DataProvider('provideHavingBetweenInvalidValuesThrowInvalidArgumentException')] + public function testHavingBetweenInvalidValuesThrowInvalidArgumentException($values): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = new BaseBuilder('user', $this->db); + $builder->havingBetween('total', $values); + } + + /** + * @return iterable + */ + public static function provideHavingBetweenInvalidValuesThrowInvalidArgumentException(): iterable + { + return [ + 'null' => [null], + 'empty array' => [[]], + 'one value' => [[10]], + 'three values' => [[10, 20, 30]], + ]; } public function testHavingIn(): void @@ -81,7 +211,7 @@ public function testHavingIn(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" IN (1,2)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingInClosure(): void @@ -94,7 +224,7 @@ public function testHavingInClosure(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" IN (SELECT "user_id" FROM "users_jobs" WHERE "group_id" = 3)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingIn(): void @@ -108,7 +238,7 @@ public function testOrHavingIn(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" IN (1,2) OR "group_id" IN (5,6)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingInClosure(): void @@ -122,7 +252,7 @@ public function testOrHavingInClosure(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" IN (SELECT "user_id" FROM "users_jobs" WHERE "group_id" = 3) OR "group_id" IN (SELECT "group_id" FROM "groups" WHERE "group_id" = 6)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingNotIn(): void @@ -135,7 +265,7 @@ public function testHavingNotIn(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" NOT IN (1,2)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingNotInClosure(): void @@ -148,7 +278,7 @@ public function testHavingNotInClosure(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" NOT IN (SELECT "user_id" FROM "users_jobs" WHERE "group_id" = 3)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingNotIn(): void @@ -162,7 +292,7 @@ public function testOrHavingNotIn(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" NOT IN (1,2) OR "group_id" NOT IN (5,6)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingNotInClosure(): void @@ -176,7 +306,7 @@ public function testOrHavingNotInClosure(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "id" NOT IN (SELECT "user_id" FROM "users_jobs" WHERE "group_id" = 3) OR "group_id" NOT IN (SELECT "group_id" FROM "groups" WHERE "group_id" = 6)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingLike(): void @@ -189,7 +319,7 @@ public function testHavingLike(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'%a%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingLikeBefore(): void @@ -202,7 +332,7 @@ public function testHavingLikeBefore(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'%a\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingLikeAfter(): void @@ -215,7 +345,7 @@ public function testHavingLikeAfter(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'a%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testNotHavingLike(): void @@ -228,7 +358,7 @@ public function testNotHavingLike(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" NOT LIKE \'%a%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testNotHavingLikeBefore(): void @@ -241,7 +371,7 @@ public function testNotHavingLikeBefore(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" NOT LIKE \'%a\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testNotHavingLikeAfter(): void @@ -254,7 +384,7 @@ public function testNotHavingLikeAfter(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" NOT LIKE \'a%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingLike(): void @@ -268,7 +398,7 @@ public function testOrHavingLike(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'%a%\' ESCAPE \'!\' OR "pet_color" LIKE \'%b%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingLikeBefore(): void @@ -282,7 +412,7 @@ public function testOrHavingLikeBefore(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'%a\' ESCAPE \'!\' OR "pet_color" LIKE \'%b\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrHavingLikeAfter(): void @@ -296,7 +426,7 @@ public function testOrHavingLikeAfter(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'a%\' ESCAPE \'!\' OR "pet_color" LIKE \'b%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrNotHavingLike(): void @@ -310,7 +440,7 @@ public function testOrNotHavingLike(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'%a%\' ESCAPE \'!\' OR "pet_color" NOT LIKE \'%b%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrNotHavingLikeBefore(): void @@ -324,7 +454,7 @@ public function testOrNotHavingLikeBefore(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'%a\' ESCAPE \'!\' OR "pet_color" NOT LIKE \'%b\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrNotHavingLikeAfter(): void @@ -338,7 +468,7 @@ public function testOrNotHavingLikeAfter(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "pet_name" LIKE \'a%\' ESCAPE \'!\' OR "pet_color" NOT LIKE \'b%\' ESCAPE \'!\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingAndGroup(): void @@ -355,7 +485,7 @@ public function testHavingAndGroup(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) < 3 AND ( SUM(id) = 2 AND "name" = \'adam\' )'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testHavingOrGroup(): void @@ -372,7 +502,7 @@ public function testHavingOrGroup(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) > 3 OR ( SUM(id) = 2 AND "name" = \'adam\' )'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testNotHavingAndGroup(): void @@ -389,7 +519,7 @@ public function testNotHavingAndGroup(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) < 3 AND NOT ( SUM(id) = 2 AND "name" = \'adam\' )'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testNotHavingOrGroup(): void @@ -406,7 +536,7 @@ public function testNotHavingOrGroup(): void $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) < 3 OR NOT ( SUM(id) = 2 AND "name" = \'adam\' )'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testAndGroups(): void @@ -421,7 +551,7 @@ public function testAndGroups(): void $expectedSQL = 'SELECT * FROM "user" WHERE ( "id" > 3 AND "name" != \'Luke\' ) AND "name" = \'Darth\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrGroups(): void @@ -436,7 +566,7 @@ public function testOrGroups(): void $expectedSQL = 'SELECT * FROM "user" WHERE "name" = \'Darth\' OR ( "id" > 3 AND "name" != \'Luke\' )'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testNotGroups(): void @@ -451,7 +581,7 @@ public function testNotGroups(): void $expectedSQL = 'SELECT * FROM "user" WHERE "name" = \'Darth\' AND NOT ( "id" > 3 AND "name" != \'Luke\' )'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrNotGroups(): void @@ -466,6 +596,6 @@ public function testOrNotGroups(): void $expectedSQL = 'SELECT * FROM "user" WHERE "name" = \'Darth\' OR NOT ( "id" > 3 AND "name" != \'Luke\' )'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/InsertTest.php b/tests/system/Database/Builder/InsertTest.php index e8761b9de52b..75ea1fd0cf92 100644 --- a/tests/system/Database/Builder/InsertTest.php +++ b/tests/system/Database/Builder/InsertTest.php @@ -20,6 +20,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Enum\StatusEnum; /** * @internal @@ -61,10 +62,38 @@ public function testInsertArray(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledInsert())); + $this->assertSameSql($expectedSQL, $builder->getCompiledInsert()); $this->assertSame($expectedBinds, $builder->getBinds()); } + public function testInsertWithBackedEnum(): void + { + $builder = $this->db->table('jobs'); + + $builder->testMode()->insert([ + 'id' => 1, + 'status' => StatusEnum::ACTIVE, + ], true); + + $expectedSQL = 'INSERT INTO "jobs" ("id", "status") VALUES (1, \'active\')'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledInsert()); + } + + public function testInsertObjectWithBackedEnum(): void + { + $builder = $this->db->table('jobs'); + + $builder->testMode()->insert((object) [ + 'id' => 1, + 'status' => StatusEnum::ACTIVE, + ], true); + + $expectedSQL = 'INSERT INTO "jobs" ("id", "status") VALUES (1, \'active\')'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledInsert()); + } + public function testInsertObject(): void { $builder = $this->db->table('jobs'); @@ -87,7 +116,7 @@ public function testInsertObject(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledInsert())); + $this->assertSameSql($expectedSQL, $builder->getCompiledInsert()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -103,7 +132,7 @@ public function testInsertObjectWithRawSql(): void $expectedSQL = 'INSERT INTO "jobs" ("id", "name") VALUES (1, CONCAT("id", \'Grocery Sales\'))'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledInsert())); + $this->assertSameSql($expectedSQL, $builder->getCompiledInsert()); } /** @@ -131,7 +160,7 @@ public function testInsertWithTableAlias(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledInsert())); + $this->assertSameSql($expectedSQL, $builder->getCompiledInsert()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -171,10 +200,10 @@ public function testInsertBatch(): void $raw = <<<'SQL' INSERT INTO "jobs" ("description", "id", "name") VALUES ('There''s something in your teeth',2,'Commedian'), ('I am yellow',3,'Cab Driver') SQL; - $this->assertSame($raw, str_replace("\n", ' ', $query->getOriginalQuery())); + $this->assertSameSql($raw, $query->getOriginalQuery()); $expected = "INSERT INTO \"jobs\" (\"description\", \"id\", \"name\") VALUES ('There''s something in your teeth',2,'Commedian'), ('I am yellow',3,'Cab Driver')"; - $this->assertSame($expected, str_replace("\n", ' ', $query->getQuery())); + $this->assertSameSql($expected, $query->getQuery()); } /** @@ -206,12 +235,12 @@ public function testInsertBatchIgnore(): void $raw = <<<'SQL' INSERT IGNORE INTO "jobs" ("description", "id", "name") VALUES ('I am yellow',3,'Cab Driver') SQL; - $this->assertSame($raw, str_replace("\n", ' ', $query->getOriginalQuery())); + $this->assertSameSql($raw, $query->getOriginalQuery()); $expected = <<<'SQL' INSERT IGNORE INTO "jobs" ("description", "id", "name") VALUES ('I am yellow',3,'Cab Driver') SQL; - $this->assertSame($expected, str_replace("\n", ' ', $query->getQuery())); + $this->assertSameSql($expected, $query->getQuery()); } public function testInsertBatchWithoutEscape(): void @@ -238,7 +267,7 @@ public function testInsertBatchWithoutEscape(): void $this->assertInstanceOf(Query::class, $query); $expected = 'INSERT INTO "jobs" ("description", "id", "name") VALUES (1 + 2,2,1 + 1), (2 + 2,3,2 + 1)'; - $this->assertSame($expected, str_replace("\n", ' ', $query->getQuery())); + $this->assertSameSql($expected, $query->getQuery()); } /** @@ -262,7 +291,7 @@ public function testInsertBatchWithFieldsEndingInNumbers(): void $this->assertInstanceOf(Query::class, $query); $expected = "INSERT INTO \"ip_table\" (\"ip\", \"ip2\") VALUES ('1.1.1.0','1.1.1.2'), ('2.2.2.0','2.2.2.2'), ('3.3.3.0','3.3.3.2'), ('4.4.4.0','4.4.4.2')"; - $this->assertSame($expected, str_replace("\n", ' ', $query->getQuery())); + $this->assertSameSql($expected, $query->getQuery()); } public function testInsertBatchThrowsExceptionOnNoData(): void diff --git a/tests/system/Database/Builder/JoinTest.php b/tests/system/Database/Builder/JoinTest.php index 04145671e7df..99febc6679f4 100644 --- a/tests/system/Database/Builder/JoinTest.php +++ b/tests/system/Database/Builder/JoinTest.php @@ -44,7 +44,7 @@ public function testJoinSimple(): void $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON "user"."id" = "job"."id"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testJoinIsNull(): void @@ -55,7 +55,7 @@ public function testJoinIsNull(): void $expectedSQL = 'SELECT * FROM "table1" JOIN "table2" ON "field" IS NULL'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testJoinIsNotNull(): void @@ -66,7 +66,7 @@ public function testJoinIsNotNull(): void $expectedSQL = 'SELECT * FROM "table1" JOIN "table2" ON "field" IS NOT NULL'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testJoinMultipleConditions(): void @@ -77,7 +77,7 @@ public function testJoinMultipleConditions(): void $expectedSQL = "SELECT * FROM \"table1\" LEFT JOIN \"table2\" ON \"table1\".\"field1\" = \"table2\".\"field2\" AND \"table1\".\"field1\" = 'foo' AND \"table2\".\"field2\" = 0"; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -96,7 +96,7 @@ public function testJoinMultipleConditionsBetween(): void // @TODO Should be `... CURDATE() BETWEEN "lease_start_date" AND "lease_exp_date"` $expectedSQL = 'SELECT * FROM "table1" LEFT JOIN "leases" ON "units"."unit_id" = "leases"."unit_id" AND CURDATE() BETWEEN lease_start_date AND lease_exp_date'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -129,7 +129,7 @@ public function testFullOuterJoin(): void $expectedSQL = 'SELECT * FROM "jobs" FULL OUTER JOIN "users" as "u" ON "users"."id" = "jobs"."id"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testJoinWithAlias(): void @@ -142,6 +142,32 @@ public function testJoinWithAlias(): void $expectedSQL = 'SELECT * FROM "test"."dbo"."jobs" LEFT JOIN "test"."dbo"."users" "u" ON "u"."id" = "jobs"."id"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + public function testSqlsrvJoinMultipleConditions(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + $builder->join('users u', "u.id = jobs.id AND u.status = 'active'", 'LEFT'); + + $expectedSQL = 'SELECT * FROM "test"."dbo"."jobs" LEFT JOIN "test"."dbo"."users" "u" ON "u"."id" = "jobs"."id" AND "u"."status" = \'active\''; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + public function testSqlsrvJoinRawSql(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + $builder->join('users u', new RawSql('u.id = jobs.id AND u.deleted_at IS NULL'), 'LEFT'); + + $expectedSQL = 'SELECT * FROM "test"."dbo"."jobs" LEFT JOIN "test"."dbo"."users" "u" ON u.id = jobs.id AND u.deleted_at IS NULL'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/LikeTest.php b/tests/system/Database/Builder/LikeTest.php index f9d7cd91f65e..6fff62e110dd 100644 --- a/tests/system/Database/Builder/LikeTest.php +++ b/tests/system/Database/Builder/LikeTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\RawSql; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\DataProvider; @@ -49,10 +50,128 @@ public function testSimpleLike(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testLikeAny(): void + { + $builder = new BaseBuilder('job', $this->db); + + $builder->likeAny(['name', 'description'], 'veloper'); + + $expectedSQL = 'SELECT * FROM "job" WHERE ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )'; + $expectedBinds = [ + 'name' => [ + '%veloper%', + true, + ], + 'description' => [ + '%veloper%', + true, + ], + ]; + + $this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect()))); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testLikeAnyAfterWhere(): void + { + $builder = new BaseBuilder('job', $this->db); + + $builder->where('active', 1) + ->likeAny(['name', 'description'], 'veloper'); + + $expectedSQL = 'SELECT * FROM "job" WHERE "active" = 1 AND ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )'; + + $this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect()))); + } + + public function testOrLikeAnyAfterWhere(): void + { + $builder = new BaseBuilder('job', $this->db); + + $builder->where('active', 1) + ->orLikeAny(['name', 'description'], 'veloper'); + + $expectedSQL = 'SELECT * FROM "job" WHERE "active" = 1 OR ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )'; + + $this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect()))); + } + + public function testLikeAnyCaseInsensitiveSearch(): void + { + $builder = new BaseBuilder('job', $this->db); + + $builder->likeAny(['name', 'description'], 'VELOPER', 'both', null, true); + + $expectedSQL = 'SELECT * FROM "job" WHERE ( LOWER("name") LIKE \'%veloper%\' ESCAPE \'!\' OR LOWER("description") LIKE \'%veloper%\' ESCAPE \'!\' )'; + $expectedBinds = [ + 'name' => [ + '%veloper%', + true, + ], + 'description' => [ + '%veloper%', + true, + ], + ]; + + $this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect()))); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testLikeAnyWithRawSqlField(): void + { + $builder = new BaseBuilder('job', $this->db); + $rawSql = new RawSql('LOWER(description)'); + + $builder->likeAny(['name', $rawSql], 'veloper', 'after'); + + $expectedSQL = 'SELECT * FROM "job" WHERE ( "name" LIKE \'veloper%\' ESCAPE \'!\' OR LOWER(description) LIKE \'veloper%\' ESCAPE \'!\' )'; + $expectedBinds = [ + 'name' => [ + 'veloper%', + true, + ], + $rawSql->getBindingKey() => [ + 'veloper%', + true, + ], + ]; + + $this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect()))); $this->assertSame($expectedBinds, $builder->getBinds()); } + /** + * @param mixed $fields + */ + #[DataProvider('provideLikeAnyInvalidFieldsThrowInvalidArgumentException')] + public function testLikeAnyInvalidFieldsThrowInvalidArgumentException($fields): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = new BaseBuilder('job', $this->db); + + $builder->likeAny($fields, 'veloper'); + } + + /** + * @return iterable + */ + public static function provideLikeAnyInvalidFieldsThrowInvalidArgumentException(): iterable + { + return [ + 'empty list' => [[]], + 'assoc array' => [['name' => 'description']], + 'empty field' => [['name', '']], + 'blank field' => [['name', ' ']], + 'int field' => [['name', 1]], + ]; + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/3970 */ @@ -72,7 +191,7 @@ public function testLikeWithRawSql(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -90,7 +209,7 @@ public function testLikeNoSide(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -108,7 +227,7 @@ public function testLikeBeforeOnly(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -126,7 +245,7 @@ public function testLikeAfterOnly(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -148,7 +267,7 @@ public function testOrLike(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -166,7 +285,7 @@ public function testNotLike(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -188,7 +307,7 @@ public function testOrNotLike(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -206,7 +325,7 @@ public function testCaseInsensitiveLike(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -229,7 +348,7 @@ public function testDBPrefixAndCoulmnWithTablename(): void true, ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } diff --git a/tests/system/Database/Builder/LimitTest.php b/tests/system/Database/Builder/LimitTest.php index 4e7bbfd40203..fbd23d116490 100644 --- a/tests/system/Database/Builder/LimitTest.php +++ b/tests/system/Database/Builder/LimitTest.php @@ -41,7 +41,7 @@ public function testLimitAlone(): void $expectedSQL = 'SELECT * FROM "user" LIMIT 5'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testLimitAndOffset(): void @@ -52,7 +52,7 @@ public function testLimitAndOffset(): void $expectedSQL = 'SELECT * FROM "user" LIMIT 1, 5'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testLimitAndOffsetMethod(): void @@ -63,6 +63,6 @@ public function testLimitAndOffsetMethod(): void $expectedSQL = 'SELECT * FROM "user" LIMIT 1, 5'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/OrderTest.php b/tests/system/Database/Builder/OrderTest.php index b1b2a684d9df..36cbac106fda 100644 --- a/tests/system/Database/Builder/OrderTest.php +++ b/tests/system/Database/Builder/OrderTest.php @@ -41,7 +41,7 @@ public function testOrderAscending(): void $expectedSQL = 'SELECT * FROM "user" ORDER BY "name" ASC'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrderDescending(): void @@ -52,7 +52,7 @@ public function testOrderDescending(): void $expectedSQL = 'SELECT * FROM "user" ORDER BY "name" DESC'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrderRandom(): void @@ -63,7 +63,7 @@ public function testOrderRandom(): void $expectedSQL = 'SELECT * FROM "user" ORDER BY RAND()'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrderRandomWithRandomColumn(): void @@ -76,6 +76,6 @@ public function testOrderRandomWithRandomColumn(): void $expectedSQL = 'SELECT * FROM "fail_user" ORDER BY "SYSTEM"."RANDOM"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/PrefixTest.php b/tests/system/Database/Builder/PrefixTest.php index 03b8cd99e4d3..dcd58a46e035 100644 --- a/tests/system/Database/Builder/PrefixTest.php +++ b/tests/system/Database/Builder/PrefixTest.php @@ -51,7 +51,42 @@ public function testPrefixesSetOnTableNamesWithWhereClause(): void $builder->where($where); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testPrefixesSetOnTableNamesWithWhereColumnClause(): void + { + $builder = $this->db->table('users'); + + $expectedSQL = 'SELECT * FROM "ci_users" WHERE "ci_users"."created_at" < "ci_users"."updated_at"'; + $expectedBinds = []; + + $builder->whereColumn('users.created_at <', 'users.updated_at'); + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testPrefixesSetOnTableNamesWithWhereBetweenClause(): void + { + $builder = $this->db->table('users'); + + $expectedSQL = 'SELECT * FROM "ci_users" WHERE "ci_users"."created_at" BETWEEN 1 AND 10'; + $expectedBinds = [ + 'users.created_at' => [ + 1, + true, + ], + 'users.created_at.1' => [ + 10, + true, + ], + ]; + + $builder->whereBetween('users.created_at', [1, 10]); + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } diff --git a/tests/system/Database/Builder/SelectTest.php b/tests/system/Database/Builder/SelectTest.php index 0b377e408730..6054184cee7e 100644 --- a/tests/system/Database/Builder/SelectTest.php +++ b/tests/system/Database/Builder/SelectTest.php @@ -14,8 +14,13 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\MySQLi\Builder as MySQLiBuilder; +use CodeIgniter\Database\OCI8\Builder as OCI8Builder; +use CodeIgniter\Database\Postgre\Builder as PostgreBuilder; use CodeIgniter\Database\RawSql; +use CodeIgniter\Database\SQLite3\Builder as SQLite3Builder; use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; @@ -43,7 +48,7 @@ public function testSimpleSelect(): void $expected = 'SELECT * FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectOnlyOneColumn(): void @@ -54,7 +59,7 @@ public function testSelectOnlyOneColumn(): void $expected = 'SELECT "name" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectAcceptsArray(): void @@ -65,7 +70,7 @@ public function testSelectAcceptsArray(): void $expected = 'SELECT "name", "role" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } /** @@ -78,7 +83,7 @@ public function testSelectAcceptsArrayWithRawSql(array $select, string $expected $builder->select($select); - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } /** @@ -135,7 +140,7 @@ public function testSelectAcceptsMultipleColumns(): void $expected = 'SELECT "name", "role" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectKeepsAliases(): void @@ -146,7 +151,7 @@ public function testSelectKeepsAliases(): void $expected = 'SELECT "name", "role" as "myRole" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectWorksWithComplexSelects(): void @@ -157,7 +162,7 @@ public function testSelectWorksWithComplexSelects(): void $expected = 'SELECT (SELECT SUM(payments.amount) FROM payments WHERE payments.invoice_id=4) AS amount_paid FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectNullAsInString(): void @@ -168,7 +173,7 @@ public function testSelectNullAsInString(): void $expected = 'SELECT NULL as field_alias, "name" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectNullAsInArray(): void @@ -179,7 +184,7 @@ public function testSelectNullAsInArray(): void $expected = 'SELECT NULL as field_alias, "name" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } /** @@ -193,7 +198,7 @@ public function testSelectWorksWithRawSql(): void $builder->select(new RawSql($sql)); $expected = 'SELECT ' . $sql . ' FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } /** @@ -207,7 +212,7 @@ public function testSelectWorksWithEscpaeFalse(): void $expected = 'SELECT "numericValue1" + "numericValue2" AS "numericResult" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } /** @@ -237,7 +242,7 @@ public function testSelectMinWithNoAlias(): void $expected = 'SELECT MIN("payments") AS "payments" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectMinWithAlias(): void @@ -248,7 +253,7 @@ public function testSelectMinWithAlias(): void $expected = 'SELECT MIN("payments") AS "myAlias" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectMaxWithNoAlias(): void @@ -259,7 +264,7 @@ public function testSelectMaxWithNoAlias(): void $expected = 'SELECT MAX("payments") AS "payments" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectMaxWithAlias(): void @@ -270,7 +275,7 @@ public function testSelectMaxWithAlias(): void $expected = 'SELECT MAX("payments") AS "myAlias" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectAvgWithNoAlias(): void @@ -281,7 +286,7 @@ public function testSelectAvgWithNoAlias(): void $expected = 'SELECT AVG("payments") AS "payments" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectAvgWithAlias(): void @@ -292,7 +297,7 @@ public function testSelectAvgWithAlias(): void $expected = 'SELECT AVG("payments") AS "myAlias" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectSumWithNoAlias(): void @@ -303,7 +308,7 @@ public function testSelectSumWithNoAlias(): void $expected = 'SELECT SUM("payments") AS "payments" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectSumWithAlias(): void @@ -314,7 +319,7 @@ public function testSelectSumWithAlias(): void $expected = 'SELECT SUM("payments") AS "myAlias" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectCountWithNoAlias(): void @@ -325,7 +330,7 @@ public function testSelectCountWithNoAlias(): void $expected = 'SELECT COUNT("payments") AS "payments" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectCountWithAlias(): void @@ -336,7 +341,7 @@ public function testSelectCountWithAlias(): void $expected = 'SELECT COUNT("payments") AS "myAlias" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectMinThrowsExceptionOnEmptyValue(): void @@ -357,7 +362,7 @@ public function testSelectMaxWithDotNameAndNoAlias(): void $expected = 'SELECT MAX("db"."payments") AS "payments" FROM "invoices"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectMinThrowsExceptionOnMultipleColumn(): void @@ -378,7 +383,441 @@ public function testSimpleSelectWithSQLSRV(): void $expected = 'SELECT * FROM "test"."dbo"."users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); + } + + public function testLockForUpdate(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->where('id', 1)->orderBy('id', 'ASC')->limit(1)->lockForUpdate(); + + $expected = 'SELECT * FROM "users" WHERE "id" = 1 ORDER BY "id" ASC LIMIT 1 FOR UPDATE'; + + $this->assertSameSql($expected, $builder->getCompiledSelect()); + } + + public function testLockForUpdatePersistsWhenSelectIsNotReset(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->lockForUpdate(); + + $expected = 'SELECT * FROM "users" FOR UPDATE'; + + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + } + + public function testLockForUpdateResetsWithSelect(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->lockForUpdate(); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE', $builder->getCompiledSelect()); + $this->assertSameSql('SELECT * FROM "users"', $builder->getCompiledSelect()); + } + + public function testSharedLock(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->where('id', 1)->orderBy('id', 'ASC')->limit(1)->sharedLock(); + + $expected = 'SELECT * FROM "users" WHERE "id" = 1 ORDER BY "id" ASC LIMIT 1 FOR SHARE'; + + $this->assertSameSql($expected, $builder->getCompiledSelect()); + } + + public function testSharedLockPersistsWhenSelectIsNotReset(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->sharedLock(); + + $expected = 'SELECT * FROM "users" FOR SHARE'; + + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); + } + + public function testSharedLockResetsWithSelect(): void + { + $builder = new BaseBuilder('users', $this->db); + + $builder->sharedLock(); + + $this->assertSameSql('SELECT * FROM "users" FOR SHARE', $builder->getCompiledSelect()); + $this->assertSameSql('SELECT * FROM "users"', $builder->getCompiledSelect()); + } + + public function testSelectLockLastCallWins(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR UPDATE', $builder->sharedLock()->lockForUpdate()->getCompiledSelect()); + + $builder = new BaseBuilder('users', $this->db); + + $this->assertSameSql('SELECT * FROM "users" FOR SHARE', $builder->lockForUpdate()->sharedLock()->getCompiledSelect()); + } + + public function testLockForUpdateThrowsExceptionWithUnion(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support lockForUpdate() with union() or unionAll().'); + + $builder->union(new BaseBuilder('jobs', $this->db))->lockForUpdate()->getCompiledSelect(); + } + + public function testSharedLockThrowsExceptionWithUnion(): void + { + $builder = new BaseBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support sharedLock() with union() or unionAll().'); + + $builder->union(new BaseBuilder('jobs', $this->db))->sharedLock()->getCompiledSelect(); + } + + public function testLockForUpdateThrowsExceptionWithSQLSRVUnion(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support lockForUpdate() with union() or unionAll().'); + + $builder->union(new SQLSRVBuilder('jobs', $this->db))->lockForUpdate()->getCompiledSelect(); + } + + public function testSharedLockThrowsExceptionWithSQLSRVUnion(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query Builder does not support sharedLock() with union() or unionAll().'); + + $builder->union(new SQLSRVBuilder('jobs', $this->db))->sharedLock()->getCompiledSelect(); + } + + public function testLockForUpdateThrowsExceptionOnMySQLiSubquery(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $subquery = new MySQLiBuilder('users', $this->db); + $builder = new MySQLiBuilder('jobs', $this->db); + + $builder->fromSubquery($subquery, 'users_1'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('MySQLi does not support lockForUpdate() with fromSubquery().'); + + $builder->lockForUpdate()->getCompiledSelect(); + } + + public function testSharedLockThrowsExceptionOnMySQLiSubquery(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $subquery = new MySQLiBuilder('users', $this->db); + $builder = new MySQLiBuilder('jobs', $this->db); + + $builder->fromSubquery($subquery, 'users_1'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('MySQLi does not support sharedLock() with fromSubquery().'); + + $builder->sharedLock()->getCompiledSelect(); + } + + public function testSharedLockWithMySQLi(): void + { + $this->db = new MockConnection(['DBDriver' => 'MySQLi']); + + $builder = new MySQLiBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" LOCK IN SHARE MODE'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + + public function testLockForUpdateWithOCI8(): void + { + $builder = new OCI8Builder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); + } + + public function testSharedLockThrowsExceptionOnOCI8(): void + { + $builder = new OCI8Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support sharedLock().'); + + $builder->sharedLock()->getCompiledSelect(); + } + + public function testLockForUpdateThrowsExceptionWithOCI8Limit(): void + { + $builder = new OCI8Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support lockForUpdate() with limit() or offset().'); + + $builder->limit(1)->lockForUpdate()->getCompiledSelect(); + } + + #[DataProvider('provideSelectLockUnsupportedSelectClauses')] + public function testLockForUpdateThrowsExceptionWithOCI8SelectClause(string $clause): void + { + $builder = new OCI8Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); + + $this->applySelectLockUnsupportedClause($builder, $clause) + ->lockForUpdate() + ->getCompiledSelect(); + } + + public function testLockForUpdateWithPostgre(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR UPDATE'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); + } + + public function testSharedLockWithPostgre(): void + { + $builder = new PostgreBuilder('users', $this->db); + + $expected = 'SELECT * FROM "users" FOR SHARE'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + + #[DataProvider('provideSelectLockUnsupportedSelectClauses')] + public function testLockForUpdateThrowsExceptionWithPostgreSelectClause(string $clause): void + { + $builder = new PostgreBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Postgre does not support lockForUpdate() with distinct(), groupBy(), having(), or aggregate helper selections.'); + + $this->applySelectLockUnsupportedClause($builder, $clause) + ->lockForUpdate() + ->getCompiledSelect(); + } + + #[DataProvider('provideSelectLockUnsupportedSelectClauses')] + public function testSharedLockThrowsExceptionWithPostgreSelectClause(string $clause): void + { + $builder = new PostgreBuilder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Postgre does not support sharedLock() with distinct(), groupBy(), having(), or aggregate helper selections.'); + + $this->applySelectLockUnsupportedClause($builder, $clause) + ->sharedLock() + ->getCompiledSelect(); + } + + /** + * @return iterable> + */ + public static function provideSelectLockUnsupportedSelectClauses(): iterable + { + yield 'distinct' => ['distinct']; + + yield 'groupBy' => ['groupBy']; + + yield 'having' => ['having']; + + yield 'aggregate selection' => ['aggregate']; + } + + private function applySelectLockUnsupportedClause(BaseBuilder $builder, string $clause): BaseBuilder + { + return match ($clause) { + 'distinct' => $builder->distinct(), + 'groupBy' => $builder->groupBy('role'), + 'having' => $builder->having('COUNT(id) >', 1, false), + 'aggregate' => $builder->selectCount('id'), + default => throw new DatabaseException('Unsupported clause: ' . $clause), + }; + } + + public function testLockForUpdateThrowsExceptionOnSQLite3(): void + { + $builder = new SQLite3Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLite3 does not support lockForUpdate().'); + + $builder->lockForUpdate()->getCompiledSelect(); + } + + public function testSharedLockThrowsExceptionOnSQLite3(): void + { + $builder = new SQLite3Builder('users', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLite3 does not support sharedLock().'); + + $builder->sharedLock()->getCompiledSelect(); + } + + public function testLockForUpdateWithSQLSRV(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" WITH (UPDLOCK, ROWLOCK)'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); + } + + public function testSharedLockWithSQLSRV(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" WITH (HOLDLOCK, ROWLOCK)'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + + public function testLockForUpdateWithSQLSRVAlias(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users u', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" "u" WITH (UPDLOCK, ROWLOCK)'; + + $this->assertSameSql($expected, $builder->lockForUpdate()->getCompiledSelect()); + } + + public function testSharedLockWithSQLSRVAlias(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users u', $this->db); + + $expected = 'SELECT * FROM "test"."dbo"."users" "u" WITH (HOLDLOCK, ROWLOCK)'; + + $this->assertSameSql($expected, $builder->sharedLock()->getCompiledSelect()); + } + + public function testLockForUpdateWithSQLSRVLimit(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('users', $this->db); + + $builder->where('id', 1)->orderBy('id', 'ASC')->limit(1)->lockForUpdate(); + + $expected = 'SELECT * FROM "test"."dbo"."users" WITH (UPDLOCK, ROWLOCK) WHERE "id" = 1 ORDER BY "id" ASC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY'; + + $this->assertSame($expected, trim(str_replace("\n", ' ', $builder->getCompiledSelect()))); + } + + public function testLockForUpdateWithSQLSRVJoin(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + + $builder->join('users u', 'u.id = jobs.id', 'LEFT')->lockForUpdate(); + + $expected = 'SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) LEFT JOIN "test"."dbo"."users" "u" ON "u"."id" = "jobs"."id"'; + + $this->assertSameSql($expected, $builder->getCompiledSelect()); + } + + public function testSharedLockWithSQLSRVJoin(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + + $builder->join('users u', 'u.id = jobs.id', 'LEFT')->sharedLock(); + + $expected = 'SELECT * FROM "test"."dbo"."jobs" WITH (HOLDLOCK, ROWLOCK) LEFT JOIN "test"."dbo"."users" "u" ON "u"."id" = "jobs"."id"'; + + $this->assertSameSql($expected, $builder->getCompiledSelect()); + } + + public function testLockForUpdateThrowsExceptionOnSQLSRVWithoutFromTable(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = (new SQLSRVBuilder('users', $this->db)) + ->from([], true) + ->select('1', false); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support lockForUpdate() without a FROM table.'); + + $builder->lockForUpdate()->getCompiledSelect(); + } + + public function testSharedLockThrowsExceptionOnSQLSRVWithoutFromTable(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = (new SQLSRVBuilder('users', $this->db)) + ->from([], true) + ->select('1', false); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support sharedLock() without a FROM table.'); + + $builder->sharedLock()->getCompiledSelect(); + } + + public function testLockForUpdateThrowsExceptionOnSQLSRVSubquery(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $subquery = new SQLSRVBuilder('users', $this->db); + $builder = new SQLSRVBuilder('jobs', $this->db); + + $builder->fromSubquery($subquery, 'users_1'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support lockForUpdate() on subqueries.'); + + $builder->lockForUpdate()->getCompiledSelect(); + } + + public function testSharedLockThrowsExceptionOnSQLSRVSubquery(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $subquery = new SQLSRVBuilder('users', $this->db); + $builder = new SQLSRVBuilder('jobs', $this->db); + + $builder->fromSubquery($subquery, 'users_1'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support sharedLock() on subqueries.'); + + $builder->sharedLock()->getCompiledSelect(); } public function testSelectSubquery(): void @@ -391,7 +830,7 @@ public function testSelectSubquery(): void $expected = 'SELECT "name", (SELECT "name" FROM "countries" WHERE "id" = 1) "country" FROM "users"'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } public function testSelectResetQuery(): void @@ -402,10 +841,7 @@ public function testSelectResetQuery(): void $builder->resetQuery(); $sql = $builder->getCompiledSelect(); - $this->assertSame( - 'SELECT * FROM "users"', - str_replace("\n", ' ', $sql), - ); + $this->assertSameSql('SELECT * FROM "users"', $sql); } /** @@ -419,12 +855,12 @@ public function testGetCompiledSelect(): void $expected = 'SELECT "name", "role" FROM "users" ORDER BY "name" DESC'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect(false))); + $this->assertSameSql($expected, $builder->getCompiledSelect(false)); $builder->orderBy('role', 'desc'); $expected = 'SELECT "name", "role" FROM "users" ORDER BY "name" DESC, "role" DESC'; - $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expected, $builder->getCompiledSelect()); } } diff --git a/tests/system/Database/Builder/UpdateTest.php b/tests/system/Database/Builder/UpdateTest.php index 5bb93ad2a598..6cb1c69ad239 100644 --- a/tests/system/Database/Builder/UpdateTest.php +++ b/tests/system/Database/Builder/UpdateTest.php @@ -57,7 +57,7 @@ public function testUpdateArray(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -80,7 +80,7 @@ public function testUpdateObject(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -102,7 +102,7 @@ public function testUpdateInternalWhereAndLimit(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -124,7 +124,7 @@ public function testUpdateWithSet(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -146,7 +146,7 @@ public function testUpdateWithSetAsInt(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -168,7 +168,7 @@ public function testUpdateWithSetAsBoolean(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -198,7 +198,7 @@ public function testUpdateWithSetAsArray(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -253,9 +253,7 @@ public function testUpdateBatch(): void public function testSetUpdateBatchWithoutEscape(): void { $builder = new BaseBuilder('jobs', $this->db); - $escape = false; - - $builder->setUpdateBatch([ + $builder->setData([ [ 'id' => 2, 'name' => 'SUBSTRING(name, 1)', @@ -266,7 +264,7 @@ public function testSetUpdateBatchWithoutEscape(): void 'name' => 'SUBSTRING(name, 2)', 'description' => 'SUBSTRING(description, 4)', ], - ], 'id', $escape); + ], false); $this->db->shouldReturn('execute', new class () {}); $builder->updateBatch(null, 'id'); @@ -368,7 +366,7 @@ public function testUpdateWithWhereSameColumn(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -394,7 +392,7 @@ public function testUpdateWithWhereSameColumn2(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -419,7 +417,7 @@ public function testUpdateWithWhereSameColumn3(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -443,7 +441,7 @@ public function testSetWithoutEscape(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -469,7 +467,7 @@ public function testSetWithAndWithoutEscape(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); + $this->assertSameSql($expectedSQL, $builder->getCompiledUpdate()); $this->assertSame($expectedBinds, $builder->getBinds()); } } diff --git a/tests/system/Database/Builder/WhenTest.php b/tests/system/Database/Builder/WhenTest.php index 2fc4966accb8..ec636de9f89d 100644 --- a/tests/system/Database/Builder/WhenTest.php +++ b/tests/system/Database/Builder/WhenTest.php @@ -42,14 +42,14 @@ public function testWhenTrue(): void $builder = $this->db->table('jobs'); $expectedSQL = 'SELECT * FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $builder = $builder->when(true, static function ($query): void { $query->select('id'); }); $expectedSQL = 'SELECT "id" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenTruthy(): void @@ -61,7 +61,7 @@ public function testWhenTruthy(): void }); $expectedSQL = 'SELECT "id" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenRunsDefaultWhenFalse(): void @@ -75,7 +75,7 @@ public function testWhenRunsDefaultWhenFalse(): void }); $expectedSQL = 'SELECT "name" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenDoesntModifyWhenFalse(): void @@ -87,7 +87,7 @@ public function testWhenDoesntModifyWhenFalse(): void }); $expectedSQL = 'SELECT * FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenPassesParemeters(): void @@ -100,7 +100,7 @@ public function testWhenPassesParemeters(): void }); $expectedSQL = 'SELECT * FROM "jobs" WHERE "name" = \'developer\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } #[DataProvider('provideConditionValues')] @@ -117,7 +117,7 @@ public function testWhenRunsDefaultCallbackBasedOnCondition(mixed $condition, bo $expected = $expectDefault ? 'name' : 'id'; $expectedSQL = 'SELECT "' . $expected . '" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenNotFalse(): void @@ -125,14 +125,14 @@ public function testWhenNotFalse(): void $builder = $this->db->table('jobs'); $expectedSQL = 'SELECT * FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $builder = $builder->whenNot(false, static function ($query): void { $query->select('id'); }); $expectedSQL = 'SELECT "id" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenNotFalsey(): void @@ -144,7 +144,7 @@ public function testWhenNotFalsey(): void }); $expectedSQL = 'SELECT "id" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenNotRunsDefaultWhenTrue(): void @@ -158,7 +158,7 @@ public function testWhenNotRunsDefaultWhenTrue(): void }); $expectedSQL = 'SELECT "name" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenNotDoesntModifyWhenFalse(): void @@ -170,7 +170,7 @@ public function testWhenNotDoesntModifyWhenFalse(): void }); $expectedSQL = 'SELECT * FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhenNotPassesParemeters(): void @@ -183,7 +183,7 @@ public function testWhenNotPassesParemeters(): void }); $expectedSQL = 'SELECT * FROM "jobs" WHERE "name" = \'0\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } #[DataProvider('provideConditionValues')] @@ -200,7 +200,7 @@ public function testWhenNotRunsDefaultCallbackBasedOnCondition(mixed $condition, $expected = $expectDefault ? 'id' : 'name'; $expectedSQL = 'SELECT "' . $expected . '" FROM "jobs"'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index 2d0c2a3e1233..1bde3eb08699 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -14,7 +14,9 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\RawSql; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use DateTime; @@ -23,6 +25,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use stdClass; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; /** * @internal @@ -55,7 +59,7 @@ public function testSimpleWhere(): void ]; $builder->where('id', 3); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -72,7 +76,7 @@ public function testWhereNoEscape(): void ]; $builder->where('id', 3, false); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -89,7 +93,7 @@ public function testWhereCustomKeyOperator(): void ]; $builder->where('id !=', 3); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -115,7 +119,7 @@ public function testWhereAssociateArray(): void ]; $builder->where($where); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -131,7 +135,7 @@ public function testWhereAssociateArrayKeyHasEqualValueIsNull(): void $expectedBinds = []; $builder->where($where); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -146,7 +150,54 @@ public function testWhereLikeInAssociateArray(): void $builder->where($where); $expectedSQL = 'SELECT * FROM "user" WHERE "id" < 100 AND "col1" LIKE \'%gmail%\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + /** + * @param mixed $value + * @param array $expectedBinds + */ + #[DataProvider('provideWhereOperatorRegressionCases')] + public function testWhereOperatorRegressionCases(string $key, $value, string $expectedSQL, array $expectedBinds): void + { + $builder = $this->db->table('jobs job'); + + $builder->where($key, $value); + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @return iterable}> + */ + public static function provideWhereOperatorRegressionCases(): iterable + { + return [ + 'like operator with value' => [ + 'job.status LIKE', + 'p%', + 'SELECT * FROM "jobs" "job" WHERE "job"."status" LIKE \'p%\'', + [ + 'job.status' => [ + 'p%', + true, + ], + ], + ], + 'equals operator with null' => [ + 'job.deleted_at =', + null, + 'SELECT * FROM "jobs" "job" WHERE "job"."deleted_at" IS NULL', + [], + ], + 'not equals operator with null' => [ + 'job.deleted_at !=', + null, + 'SELECT * FROM "jobs" "job" WHERE "job"."deleted_at" IS NOT NULL', + [], + ], + ]; } public function testWhereCustomString(): void @@ -159,7 +210,7 @@ public function testWhereCustomString(): void $expectedBinds = []; $builder->where($where); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -171,7 +222,7 @@ public function testWhereCustomStringWithOperatorEscapeFalse(): void $builder->where($where, null, false); $expectedSQL = 'SELECT * FROM "jobs" WHERE CURRENT_TIMESTAMP() = DATE_ADD(column, INTERVAL 2 HOUR)'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedBinds = []; $this->assertSame($expectedBinds, $builder->getBinds()); @@ -185,7 +236,7 @@ public function testWhereCustomStringWithoutOperatorEscapeFalse(): void $builder->where($where, "''", false); $expectedSQL = "SELECT * FROM \"jobs\" WHERE REPLACE(column, 'somestring', '') = ''"; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedBinds = [ "REPLACE(column, 'somestring', '')" => [ @@ -204,7 +255,7 @@ public function testWhereCustomStringWithBetweenEscapeFalse(): void $builder->where($where, null, false); $expectedSQL = "SELECT * FROM \"jobs\" WHERE created_on BETWEEN '2022-07-01 00:00:00' AND '2022-12-31 23:59:59'"; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $expectedBinds = []; $this->assertSame($expectedBinds, $builder->getBinds()); @@ -220,7 +271,7 @@ public function testWhereRawSql(): void $expectedSQL = "SELECT * FROM \"jobs\" WHERE id > 2 AND name != 'Accountant'"; $expectedBinds = []; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -293,7 +344,7 @@ public function testWhereValueSubQuery(): void $builder->where('advance_amount <', static fn (BaseBuilder $builder) => $builder->select('MAX(advance_amount)', false)->from('orders')->where('id >', 2)); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); // Builder $builder = $this->db->table('neworder'); @@ -304,7 +355,7 @@ public function testWhereValueSubQuery(): void $builder->where('advance_amount <', $subQuery); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrWhere(): void @@ -325,7 +376,7 @@ public function testOrWhere(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -347,10 +398,450 @@ public function testOrWhereSameColumn(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testWhereWithBackedEnum(): void + { + $builder = $this->db->table('jobs'); + + $builder->where('status', StatusEnum::ACTIVE); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE "status" = \'active\''; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + public function testWhereBetweenWithBackedEnums(): void + { + $builder = $this->db->table('jobs'); + + $builder->whereBetween('role', [RoleEnum::GUEST, RoleEnum::ADMIN]); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE "role" BETWEEN 0 AND 2'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + #[DataProvider('provideWhereColumnWithOperators')] + public function testWhereColumnWithOperators(string $first, string $operator): void + { + $builder = $this->db->table('users'); + + $builder->whereColumn($first, 'updated_at'); + + $expectedSQL = sprintf('SELECT * FROM "users" WHERE "created_at" %s "updated_at"', $operator); + $expectedBinds = []; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @return iterable + */ + public static function provideWhereColumnWithOperators(): iterable + { + return [ + 'default' => ['created_at', '='], + '=' => ['created_at =', '='], + '!=' => ['created_at !=', '!='], + '<>' => ['created_at <>', '<>'], + '<' => ['created_at <', '<'], + '>' => ['created_at >', '>'], + '<=' => ['created_at <=', '<='], + '>=' => ['created_at >=', '>='], + ]; + } + + public function testWhereColumnWithAlias(): void + { + $builder = $this->db->table('users u'); + + $builder->whereColumn('u.updated_at >', 'u.created_at'); + + $expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."updated_at" > "u"."created_at"'; + $expectedBinds = []; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testOrWhereColumn(): void + { + $builder = $this->db->table('users'); + + $builder->where('active', 1) + ->orWhereColumn('updated_at >', 'created_at'); + + $expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 OR "updated_at" > "created_at"'; + $expectedBinds = [ + 'active' => [ + 1, + true, + ], + ]; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } + public function testWhereColumnWithGroupedConditions(): void + { + $builder = $this->db->table('users'); + + $builder->groupStart() + ->whereColumn('created_at', 'updated_at') + ->orWhereColumn('updated_at >', 'created_at') + ->groupEnd() + ->where('active', 1); + + $expectedSQL = 'SELECT * FROM "users" WHERE ( "created_at" = "updated_at" OR "updated_at" > "created_at" ) AND "active" = 1'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + public function testWhereColumnNoEscape(): void + { + $builder = $this->db->table('users'); + + $builder->whereColumn('LOWER(users.email)', 'normalized_email', escape: false); + + $expectedSQL = 'SELECT * FROM "users" WHERE LOWER(users.email) = normalized_email'; + $expectedBinds = []; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testWhereColumnTreatsSecondArgumentAsColumnName(): void + { + $builder = $this->db->table('users'); + + $builder->whereColumn('created_at', 'like'); + + $expectedSQL = 'SELECT * FROM "users" WHERE "created_at" = "like"'; + $expectedBinds = []; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testWhereColumnIgnoresOperatorsInsideFirstArgument(): void + { + $builder = $this->db->table('users'); + + $builder->whereColumn("JSON_EXTRACT(data, '$.a>b')", 'updated_at', escape: false); + + $expectedSQL = 'SELECT * FROM "users" WHERE JSON_EXTRACT(data, \'$.a>b\') = updated_at'; + $expectedBinds = []; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + #[DataProvider('provideWhereColumnInvalidColumnThrowInvalidArgumentException')] + public function testWhereColumnInvalidColumnThrowInvalidArgumentException(string $first, string $second): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('users'); + $builder->whereColumn($first, $second); + } + + /** + * @return iterable + */ + public static function provideWhereColumnInvalidColumnThrowInvalidArgumentException(): iterable + { + return [ + 'empty first column' => ['', 'updated_at'], + 'empty second column' => ['created_at =', ''], + ]; + } + + public function testWhereExistsSubQuery(): void + { + $expectedSQL = 'SELECT * FROM "users" WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")'; + + // Closure + $builder = $this->db->table('users'); + + $builder->whereExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id')); + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + + // Builder + $builder = $this->db->table('users'); + + $subQuery = $this->db->table('orders') + ->select('1', false) + ->whereColumn('orders.user_id', 'users.id'); + + $builder->whereExists($subQuery); + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + #[DataProvider('provideWhereExistsVariants')] + public function testWhereExistsVariants(string $method, string $expectedSQL): void + { + $builder = $this->db->table('users'); + + $builder->where('active', 1); + + $builder->{$method}(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id')); + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + /** + * @return iterable + */ + public static function provideWhereExistsVariants(): iterable + { + $exists = '(SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")'; + $baseQuery = 'SELECT * FROM "users" WHERE "active" = 1'; + + return [ + 'whereExists' => ['whereExists', "{$baseQuery} AND EXISTS {$exists}"], + 'orWhereExists' => ['orWhereExists', "{$baseQuery} OR EXISTS {$exists}"], + 'whereNotExists' => ['whereNotExists', "{$baseQuery} AND NOT EXISTS {$exists}"], + 'orWhereNotExists' => ['orWhereNotExists', "{$baseQuery} OR NOT EXISTS {$exists}"], + ]; + } + + public function testWhereExistsWithGroupedConditions(): void + { + $builder = $this->db->table('users'); + + $builder->groupStart() + ->whereExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id')) + ->orWhereNotExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('jobs') + ->whereColumn('jobs.user_id', 'users.id')) + ->groupEnd() + ->where('active', 1); + + $expectedSQL = 'SELECT * FROM "users" WHERE ( EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id") OR NOT EXISTS (SELECT 1 FROM "jobs" WHERE "jobs"."user_id" = "users"."id") ) AND "active" = 1'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + public function testWhereExistsWithOuterAndInnerBinds(): void + { + $builder = $this->db->table('users'); + + $builder->where('active', 1) + ->whereExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->where('orders.status', 'paid') + ->whereColumn('orders.user_id', 'users.id')); + + $expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 AND EXISTS (SELECT 1 FROM "orders" WHERE "orders"."status" = \'paid\' AND "orders"."user_id" = "users"."id")'; + $expectedBinds = [ + 'active' => [ + 1, + true, + ], + ]; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @param mixed $subquery + */ + #[DataProvider('provideWhereExistsInvalidSubqueryThrowInvalidArgumentException')] + public function testWhereExistsInvalidSubqueryThrowInvalidArgumentException($subquery): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('users'); + $builder->whereExists($subquery); + } + + /** + * @return iterable + */ + public static function provideWhereExistsInvalidSubqueryThrowInvalidArgumentException(): iterable + { + return [ + 'null' => [null], + 'array' => [[]], + 'stdClass' => [new stdClass()], + 'raw string' => ['SELECT 1'], + ]; + } + + public function testWhereExistsSameBaseBuilderObject(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('The subquery cannot be the same object as the main query object.'); + + $builder = $this->db->table('users'); + $builder->whereExists($builder); + } + + #[DataProvider('provideWhereBetweenMethods')] + public function testWhereBetweenMethods(string $method, string $sql): void + { + $builder = $this->db->table('jobs'); + + $builder->{$method}('created_at', ['2026-01-01', '2026-01-31']); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE "created_at" ' . $sql . " '2026-01-01' AND '2026-01-31'"; + $expectedBinds = [ + 'created_at' => [ + '2026-01-01', + true, + ], + 'created_at.1' => [ + '2026-01-31', + true, + ], + ]; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @return iterable + */ + public static function provideWhereBetweenMethods(): iterable + { + return [ + 'between' => ['whereBetween', 'BETWEEN'], + 'not between' => ['whereNotBetween', 'NOT BETWEEN'], + ]; + } + + #[DataProvider('provideOrWhereBetweenMethods')] + public function testOrWhereBetweenMethods(string $method, string $sql): void + { + $builder = $this->db->table('jobs'); + + $builder->where('active', 1) + ->{$method}('created_at', ['2026-01-01', '2026-01-31']); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE "active" = 1 OR "created_at" ' . $sql . " '2026-01-01' AND '2026-01-31'"; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + /** + * @return iterable + */ + public static function provideOrWhereBetweenMethods(): iterable + { + return [ + 'or between' => ['orWhereBetween', 'BETWEEN'], + 'or not between' => ['orWhereNotBetween', 'NOT BETWEEN'], + ]; + } + + public function testWhereBetweenWithGroupedConditions(): void + { + $builder = $this->db->table('jobs'); + + $builder->groupStart() + ->whereBetween('created_at', ['2026-01-01', '2026-01-31']) + ->orWhereNotBetween('updated_at', ['2026-02-01', '2026-02-28']) + ->groupEnd() + ->where('active', 1); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE ( "created_at" BETWEEN \'2026-01-01\' AND \'2026-01-31\' OR "updated_at" NOT BETWEEN \'2026-02-01\' AND \'2026-02-28\' ) AND "active" = 1'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + public function testWhereBetweenNoEscape(): void + { + $builder = $this->db->table('jobs'); + + $builder->whereBetween('DATE(created_at)', ['20260101', '20260131'], escape: false); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE DATE(created_at) BETWEEN 20260101 AND 20260131'; + $expectedBinds = [ + 'DATE(created_at)' => [ + '20260101', + false, + ], + 'DATE(created_at).1' => [ + '20260131', + false, + ], + ]; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testWhereBetweenWithAliasBeforeFrom(): void + { + $builder = $this->db->newQuery(); + + $builder->whereBetween('u.created_at', ['2026-01-01', '2026-01-31']) + ->from('users u'); + + $expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."created_at" BETWEEN \'2026-01-01\' AND \'2026-01-31\''; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + + /** + * @param mixed $key + */ + #[DataProvider('provideWhereInvalidKeyThrowInvalidArgumentException')] + public function testWhereBetweenInvalidKeyThrowInvalidArgumentException($key): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('jobs'); + $builder->whereBetween($key, ['2026-01-01', '2026-01-31']); + } + + /** + * @param mixed $values + */ + #[DataProvider('provideWhereBetweenInvalidValuesThrowInvalidArgumentException')] + public function testWhereBetweenInvalidValuesThrowInvalidArgumentException($values): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('jobs'); + $builder->whereBetween('created_at', $values); + } + + /** + * @return iterable + */ + public static function provideWhereBetweenInvalidValuesThrowInvalidArgumentException(): iterable + { + return [ + 'null' => [null], + 'empty array' => [[]], + 'one value' => [['2026-01-01']], + 'three values' => [ + ['2026-01-01', '2026-01-31', '2026-02-28'], + ], + ]; + } + public function testWhereIn(): void { $builder = $this->db->table('jobs'); @@ -368,10 +859,21 @@ public function testWhereIn(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } + public function testWhereInWithBackedEnums(): void + { + $builder = $this->db->table('jobs'); + + $builder->whereIn('status', [StatusEnum::ACTIVE, StatusEnum::INACTIVE]); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE "status" IN (\'active\',\'inactive\')'; + + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); + } + public function testWhereInSubQuery(): void { $expectedSQL = 'SELECT * FROM "jobs" WHERE "id" IN (SELECT "job_id" FROM "users_jobs" WHERE "user_id" = 3)'; @@ -381,7 +883,7 @@ public function testWhereInSubQuery(): void $builder->whereIn('id', static fn (BaseBuilder $builder) => $builder->select('job_id')->from('users_jobs')->where('user_id', 3)); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); // Builder $builder = $this->db->table('jobs'); @@ -392,7 +894,7 @@ public function testWhereInSubQuery(): void $builder->whereIn('id', $subQuery); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -453,7 +955,7 @@ public function testWhereNotIn(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -466,7 +968,7 @@ public function testWhereNotInSubQuery(): void $builder->whereNotIn('id', static fn (BaseBuilder $builder) => $builder->select('job_id')->from('users_jobs')->where('user_id', 3)); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); // Builder $builder = $this->db->table('jobs'); @@ -477,7 +979,7 @@ public function testWhereNotInSubQuery(): void $builder->whereNotIn('id', $subQuery); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrWhereIn(): void @@ -501,7 +1003,7 @@ public function testOrWhereIn(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -514,7 +1016,7 @@ public function testOrWhereInSubQuery(): void $builder->where('deleted_at', null)->orWhereIn('id', static fn (BaseBuilder $builder) => $builder->select('job_id')->from('users_jobs')->where('user_id', 3)); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); // Builder $builder = $this->db->table('jobs'); @@ -525,7 +1027,7 @@ public function testOrWhereInSubQuery(): void $builder->where('deleted_at', null)->orWhereIn('id', $subQuery); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testOrWhereNotIn(): void @@ -549,7 +1051,7 @@ public function testOrWhereNotIn(): void ], ]; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); $this->assertSame($expectedBinds, $builder->getBinds()); } @@ -562,7 +1064,7 @@ public function testOrWhereNotInSubQuery(): void $builder->where('deleted_at', null)->orWhereNotIn('id', static fn (BaseBuilder $builder) => $builder->select('job_id')->from('users_jobs')->where('user_id', 3)); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); // Builder $builder = $this->db->table('jobs'); @@ -573,7 +1075,7 @@ public function testOrWhereNotInSubQuery(): void $builder->where('deleted_at', null)->orWhereNotIn('id', $subQuery); - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -585,7 +1087,7 @@ public function testWhereWithLower(): void $builder->where('LOWER(jobs.name)', 'accountant'); $expectedSQL = 'SELECT * FROM "jobs" WHERE LOWER(jobs.name) = \'accountant\''; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhereValueIsString(): void @@ -597,7 +1099,7 @@ public function testWhereValueIsString(): void $expectedSQL = <<<'SQL' SELECT * FROM "users" WHERE "id" = '1' SQL; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } public function testWhereValueIsFloat(): void @@ -609,7 +1111,7 @@ public function testWhereValueIsFloat(): void $expectedSQL = <<<'SQL' SELECT * FROM "users" WHERE "id" = 1.234 SQL; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -624,7 +1126,7 @@ public function testWhereValueIsTrue(): void $builder->where('id', true); $expectedSQL = 'SELECT * FROM "users" WHERE "id" = 1'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -639,7 +1141,7 @@ public function testWhereValueIsFalse(): void $builder->where('id', false); $expectedSQL = 'SELECT * FROM "users" WHERE "id" = 0'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -655,7 +1157,7 @@ public function testWhereValueIsArray(): void $expectedSQL = <<<'SQL' SELECT * FROM "users" WHERE "id" = ('a','b') SQL; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** @@ -695,7 +1197,7 @@ public function testWhereValueIsNull(): void $builder->where('id', null); $expectedSQL = 'SELECT * FROM "users" WHERE "id" IS NULL'; - $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSameSql($expectedSQL, $builder->getCompiledSelect()); } /** diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index 1d2482e0ad87..22b94974c0e2 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -80,7 +80,6 @@ final class ConfigTest extends CIUnitTestCase 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, 'failover' => [], 'port' => 5432, ]; @@ -99,7 +98,6 @@ final class ConfigTest extends CIUnitTestCase 'swapPre' => '', 'encrypt' => false, 'compress' => false, - 'strictOn' => true, 'failover' => [], 'port' => 5432, ]; @@ -163,7 +161,6 @@ public function testConnectionGroupWithDSNPostgre(): void $this->assertFalse($this->getPrivateProperty($conn, 'pConnect')); $this->assertSame('utf8mb4', $this->getPrivateProperty($conn, 'charset')); $this->assertSame('utf8mb4_general_ci', $this->getPrivateProperty($conn, 'DBCollat')); - $this->assertTrue($this->getPrivateProperty($conn, 'strictOn')); $this->assertSame([], $this->getPrivateProperty($conn, 'failover')); $this->assertSame('5', $this->getPrivateProperty($conn, 'connect_timeout')); $this->assertSame('1', $this->getPrivateProperty($conn, 'sslmode')); @@ -191,7 +188,6 @@ public function testConnectionGroupWithDSNPostgreNative(): void $this->assertFalse($this->getPrivateProperty($conn, 'pConnect')); $this->assertSame('utf8mb4', $this->getPrivateProperty($conn, 'charset')); $this->assertSame('utf8mb4_general_ci', $this->getPrivateProperty($conn, 'DBCollat')); - $this->assertTrue($this->getPrivateProperty($conn, 'strictOn')); $this->assertSame([], $this->getPrivateProperty($conn, 'failover')); } diff --git a/tests/system/Database/DatabaseExceptionTest.php b/tests/system/Database/DatabaseExceptionTest.php new file mode 100644 index 000000000000..43dfca1b92e8 --- /dev/null +++ b/tests/system/Database/DatabaseExceptionTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class DatabaseExceptionTest extends CIUnitTestCase +{ + public function testIntCodeIsAvailableViaGetCodeAndGetDatabaseCode(): void + { + $exception = new DatabaseException('error', 1062); + + $this->assertSame(1062, $exception->getCode()); + $this->assertSame(1062, $exception->getDatabaseCode()); + } + + public function testStringCodeIsAvailableViaGetDatabaseCodeWithoutAffectingGetCode(): void + { + $exception = new DatabaseException('error', '23505'); + + $this->assertSame(0, $exception->getCode()); + $this->assertSame('23505', $exception->getDatabaseCode()); + } + + public function testStringCodeWithSlashIsAvailableViaGetDatabaseCode(): void + { + $exception = new DatabaseException('error', '23000/2601'); + + $this->assertSame(0, $exception->getCode()); + $this->assertSame('23000/2601', $exception->getDatabaseCode()); + } +} diff --git a/tests/system/Database/DatabaseSeederTest.php b/tests/system/Database/DatabaseSeederTest.php index e00599e26507..a34f19eda88d 100644 --- a/tests/system/Database/DatabaseSeederTest.php +++ b/tests/system/Database/DatabaseSeederTest.php @@ -15,7 +15,6 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\Database; -use Faker\Generator; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Database\Seeds\SeederWithDBGroup; use Tests\Support\Database\Seeds\SeederWithoutDBGroup; @@ -52,14 +51,6 @@ public function testInstantiateNotDirSeedPath(): void new Seeder($config); } - /** - * @TODO remove this when Seeder::faker() is removed - */ - public function testFakerGet(): void - { - $this->assertInstanceOf(Generator::class, Seeder::faker()); - } - public function testCallOnEmptySeeder(): void { $this->expectException('InvalidArgumentException'); diff --git a/tests/system/Database/Live/ConnectTest.php b/tests/system/Database/Live/ConnectTest.php index e41fdbdfc114..438b54b96151 100644 --- a/tests/system/Database/Live/ConnectTest.php +++ b/tests/system/Database/Live/ConnectTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Database\SQLite3\Connection; +use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use Config\Database; @@ -133,4 +134,90 @@ public function testNonSharedInstanceDoesNotAffectSharedInstances(): void $this->assertSame($originalDebugValue, self::getPrivateProperty($secondSharedDb, 'DBDebug')); $this->assertSame(! $originalDebugValue, self::getPrivateProperty($nonSharedDb, 'DBDebug')); } + + public function testTimezoneSetWithSpecificOffset(): void + { + $config = $this->tests; + $config['timezone'] = '+05:30'; + $driver = $config['DBDriver']; + + if (in_array($driver, ['SQLite3', 'SQLSRV'], true)) { + $this->markTestSkipped("Driver {$driver} does not support session timezone"); + } + + $db = Database::connect($config, false); + + $timezone = $this->getDatabaseTimezone($db, $driver); + + $this->assertSame('+05:30', $timezone); + } + + public function testTimezoneSetWithNamedTimezone(): void + { + $config = $this->tests; + $config['timezone'] = 'America/New_York'; + $driver = $config['DBDriver']; + + if (in_array($driver, ['SQLite3', 'SQLSRV'], true)) { + $this->markTestSkipped("Driver {$driver} does not support session timezone"); + } + + $db = Database::connect($config, false); + + $timezone = $this->getDatabaseTimezone($db, $driver); + + // Named timezones are converted to offsets + // America/New_York is either -05:00 (EST) or -04:00 (EDT) + $this->assertContains($timezone, ['-05:00', '-04:00']); + } + + public function testTimezoneAutoSyncWithAppTimezone(): void + { + $config = $this->tests; + $config['timezone'] = true; + $driver = $config['DBDriver']; + + if (in_array($driver, ['SQLite3', 'SQLSRV'], true)) { + $this->markTestSkipped("Driver {$driver} does not support session timezone"); + } + + $db = Database::connect($config, false); + + $timezone = $this->getDatabaseTimezone($db, $driver); + + $appConfig = config('App'); + $appTimezone = $appConfig->appTimezone; + $expectedOffset = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')($appTimezone); + + $this->assertSame($expectedOffset, $timezone); + } + + /** + * Helper method to get database timezone based on driver + * + * @param mixed $db + */ + private function getDatabaseTimezone($db, string $driver): string + { + switch ($driver) { + case 'MySQLi': + $result = $db->query('SELECT @@session.time_zone as tz')->getRow(); + + return $result->tz; + + case 'Postgre': + $result = $db->query('SHOW TIME ZONE')->getRow(); + + // PostgreSQL returns the timezone name, but we set it as offset + return $result->timezone ?? $result->TimeZone; + + case 'OCI8': + $result = $db->query('SELECT SESSIONTIMEZONE as tz FROM DUAL')->getRow(); + + return $result->tz ?? $result->TZ; + + default: + throw new RuntimeException("Unsupported driver: {$driver}"); + } + } } diff --git a/tests/system/Database/Live/EscapeTest.php b/tests/system/Database/Live/EscapeTest.php index adc5d414e34e..7155e952e776 100644 --- a/tests/system/Database/Live/EscapeTest.php +++ b/tests/system/Database/Live/EscapeTest.php @@ -18,6 +18,8 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; /** * @internal @@ -63,6 +65,16 @@ public function testEscapeStringable(): void $this->assertSame($expected, $sql); } + public function testEscapeStringBackedEnum(): void + { + $this->assertSame("'active'", $this->db->escape(StatusEnum::ACTIVE)); + } + + public function testEscapeIntBackedEnum(): void + { + $this->assertSame(2, $this->db->escape(RoleEnum::ADMIN)); + } + public function testEscapeString(): void { $expected = "SELECT * FROM brands WHERE name = 'O" . $this->char . "'Doules'"; @@ -111,7 +123,7 @@ public function testEscapeLikeStringDirect(): void public function testEscapeStringArray(): void { - $stringArray = [' A simple string ', new RawSql('CURRENT_TIMESTAMP()'), false, null]; + $stringArray = [' A simple string ', new RawSql('CURRENT_TIMESTAMP()'), false, null, StatusEnum::ACTIVE, RoleEnum::ADMIN]; $escapedString = $this->db->escape($stringArray); @@ -125,5 +137,7 @@ public function testEscapeStringArray(): void } $this->assertSame('NULL', $escapedString[3]); + $this->assertSame("'active'", $escapedString[4]); + $this->assertSame(2, $escapedString[5]); } } diff --git a/tests/system/Database/Live/ExecuteLogMessageFormatTest.php b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php index 9913a2da05c0..0698206c341e 100644 --- a/tests/system/Database/Live/ExecuteLogMessageFormatTest.php +++ b/tests/system/Database/Live/ExecuteLogMessageFormatTest.php @@ -48,7 +48,7 @@ public function testLogMessageWhenExecuteFailsShowFullStructuredBacktrace(): voi $pattern = match ($db->DBDriver) { 'MySQLi' => '/Table \'test\.some_table\' doesn\'t exist/', - 'Postgre' => '/pg_query\(\): Query failed: ERROR: relation "some_table" does not exist/', + 'Postgre' => '/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 "ORACLE"\."SOME_TABLE" does not exist/', 'SQLSRV' => '/\[Microsoft\]\[ODBC Driver \d+ for SQL Server\]\[SQL Server\]Invalid object name \'some_table\'/', @@ -58,9 +58,7 @@ public function testLogMessageWhenExecuteFailsShowFullStructuredBacktrace(): voi $this->assertMatchesRegularExpression($pattern, array_shift($messageFromLogs)); - if ($db->DBDriver === 'Postgre') { - $messageFromLogs = array_slice($messageFromLogs, 2); - } elseif ($db->DBDriver === 'OCI8') { + if ($db->DBDriver === 'OCI8') { $messageFromLogs = array_slice($messageFromLogs, 1); } diff --git a/tests/system/Database/Live/ExistsTest.php b/tests/system/Database/Live/ExistsTest.php new file mode 100644 index 000000000000..b652fdbc2bc6 --- /dev/null +++ b/tests/system/Database/Live/ExistsTest.php @@ -0,0 +1,69 @@ + + * + * 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; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExistsTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + public function testExistsReturnsTrueWithResults(): void + { + $this->assertTrue($this->db->table('job')->where('name', 'Developer')->exists()); + } + + public function testExistsReturnsFalseWithNoResults(): void + { + $this->assertFalse($this->db->table('job')->where('name', 'Superstar')->exists()); + } + + public function testDoesntExistReturnsFalseWithResults(): void + { + $this->assertFalse($this->db->table('job')->where('name', 'Developer')->doesntExist()); + } + + public function testDoesntExistReturnsTrueWithNoResults(): void + { + $this->assertTrue($this->db->table('job')->where('name', 'Superstar')->doesntExist()); + } + + public function testExistsHonorsReset(): void + { + $builder = $this->db->table('job'); + + $this->assertTrue($builder->where('name', 'Developer')->exists(false)); + $this->assertTrue($builder->exists()); + } + + public function testExistsHonorsLimitAndOffset(): void + { + $this->assertFalse( + $this->db->table('job') + ->orderBy('id') + ->limit(1, 10) + ->exists(), + ); + } +} diff --git a/tests/system/Database/Live/ExplainTest.php b/tests/system/Database/Live/ExplainTest.php new file mode 100644 index 000000000000..fdd1bbf457e9 --- /dev/null +++ b/tests/system/Database/Live/ExplainTest.php @@ -0,0 +1,66 @@ + + * + * 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\Exceptions\DatabaseException; +use CodeIgniter\Database\ResultInterface; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExplainTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + public function testExplainReturnsResultForSupportedDrivers(): void + { + if (in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' does not support explain().'); + } + + $result = $this->db->table('job') + ->where('name', 'Developer') + ->explain(); + + $this->assertInstanceOf(ResultInterface::class, $result); + + $expectedPrefix = $this->db->DBDriver === 'SQLite3' + ? 'EXPLAIN QUERY PLAN SELECT' + : 'EXPLAIN SELECT'; + + $this->assertStringStartsWith( + $expectedPrefix, + str_replace("\n", ' ', (string) $this->db->getLastQuery()), + ); + } + + public function testExplainThrowsForUnsupportedDrivers(): void + { + if (! in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' supports explain().'); + } + + $this->expectException(DatabaseException::class); + + $this->db->table('job')->explain(); + } +} diff --git a/tests/system/Database/Live/ForgeTest.php b/tests/system/Database/Live/ForgeTest.php index 39433abde857..62594581dd0d 100644 --- a/tests/system/Database/Live/ForgeTest.php +++ b/tests/system/Database/Live/ForgeTest.php @@ -373,7 +373,7 @@ public function testCreateTableWithNullableFieldsGivesNullDataType(): void $createTable = self::getPrivateMethodInvoker($this->forge, '_createTable'); - $sql = $createTable('forge_nullable_table', false, []); + $sql = $createTable('forge_nullable_table', []); if ($this->db->DBDriver !== 'SQLSRV') { // @see https://regex101.com/r/bIHVNw/1 diff --git a/tests/system/Database/Live/IncrementTest.php b/tests/system/Database/Live/IncrementTest.php index 3a9155566e9c..b213f7035a57 100644 --- a/tests/system/Database/Live/IncrementTest.php +++ b/tests/system/Database/Live/IncrementTest.php @@ -13,10 +13,12 @@ namespace CodeIgniter\Database\Live; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Database\Seeds\CITestSeeder; +use TypeError; /** * @internal @@ -87,6 +89,77 @@ public function testResetStateAfterIncrement(): void $this->seeInDatabase('job', ['name' => 'account2', 'description' => '11']); } + public function testIncrementMany(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->db->table('job') + ->where('name', 'job1') + ->incrementMany(['description' => 2, 'created_at' => 3]); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '8', 'created_at' => 4]); + } + + public function testIncrementManyWithValue(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->db->table('job') + ->where('name', 'job1') + ->incrementMany(['description', 'created_at'], 2); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '8', 'created_at' => 3]); + } + + public function testIncrementManyWithNegativeValue(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '-6', 'created_at' => 1]); + + $this->db->table('job') + ->where('name', 'job1') + ->incrementMany(['description' => -2, 'created_at' => -1]); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '-8', 'created_at' => 0]); + } + + public function testIncrementManyWithEmptyColumns(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument #1 ($columns) cannot be empty.'); + + $this->db->table('job') + ->where('name', 'task1') + ->incrementMany([]); + } + + public function testIncrementManyWithNonIntegerValues(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->expectException(TypeError::class); + $this->expectExceptionMessage('Argument #1 ($columns) must contain only int values, string given for "created_at".'); + + $this->db->table('job') + ->where('name', 'job1') + ->incrementMany(['description' => 2, 'created_at' => 'wrongValue']); + } + + public function testResetStateAfterIncrementMany(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + $this->hasInDatabase('job', ['name' => 'job2', 'description' => '2', 'created_at' => 4]); + + $builder = $this->db->table('job'); + + $builder->where('name', 'job1')->incrementMany(['description', 'created_at']); + $builder->where('name', 'job2')->incrementMany(['description', 'created_at']); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '7', 'created_at' => 2]); + $this->seeInDatabase('job', ['name' => 'job2', 'description' => '3', 'created_at' => 5]); + } + public function testDecrement(): void { $this->hasInDatabase('job', ['name' => 'incremental', 'description' => '6']); @@ -144,4 +217,75 @@ public function testResetStateAfterDecrement(): void $this->seeInDatabase('job', ['name' => 'account1', 'description' => '9']); $this->seeInDatabase('job', ['name' => 'account2', 'description' => '9']); } + + public function testDecrementMany(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->db->table('job') + ->where('name', 'job1') + ->decrementMany(['description' => 2, 'created_at' => 3]); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '4', 'created_at' => -2]); + } + + public function testDecrementManyWithValue(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->db->table('job') + ->where('name', 'job1') + ->decrementMany(['description', 'created_at'], 2); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '4', 'created_at' => -1]); + } + + public function testDecrementManyWithNegativeValue(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '-6', 'created_at' => 1]); + + $this->db->table('job') + ->where('name', 'job1') + ->decrementMany(['description' => -2, 'created_at' => -1]); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '-4', 'created_at' => 2]); + } + + public function testDecrementManyWithEmptyColumns(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument #1 ($columns) cannot be empty.'); + + $this->db->table('job') + ->where('name', 'task1') + ->decrementMany([]); + } + + public function testDecrementManyWithNonIntegerValues(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + + $this->expectException(TypeError::class); + $this->expectExceptionMessage('Argument #1 ($columns) must contain only int values, string given for "created_at".'); + + $this->db->table('job') + ->where('name', 'job1') + ->decrementMany(['description' => 2, 'created_at' => 'wrongValue']); + } + + public function testResetStateAfterDecrementMany(): void + { + $this->hasInDatabase('job', ['name' => 'job1', 'description' => '6', 'created_at' => 1]); + $this->hasInDatabase('job', ['name' => 'job2', 'description' => '2', 'created_at' => 4]); + + $builder = $this->db->table('job'); + + $builder->where('name', 'job1')->decrementMany(['description', 'created_at']); + $builder->where('name', 'job2')->decrementMany(['description', 'created_at']); + + $this->seeInDatabase('job', ['name' => 'job1', 'description' => '5', 'created_at' => 0]); + $this->seeInDatabase('job', ['name' => 'job2', 'description' => '1', 'created_at' => 3]); + } } diff --git a/tests/system/Database/Live/LikeTest.php b/tests/system/Database/Live/LikeTest.php index c03dcd8d096c..9ecd8f9bef80 100644 --- a/tests/system/Database/Live/LikeTest.php +++ b/tests/system/Database/Live/LikeTest.php @@ -76,6 +76,18 @@ public function testLikeCaseInsensitive(): void $this->assertSame('Developer', $job->name); } + public function testLikeAny(): void + { + $jobs = $this->db->table('job') + ->likeAny(['name', 'description'], 'bor', 'both', null, true) + ->get() + ->getResult(); + + $this->assertCount(2, $jobs); + $this->assertSame('Developer', $jobs[0]->name); + $this->assertSame('Accountant', $jobs[1]->name); + } + #[DataProvider('provideLikeCaseInsensitiveWithMultibyteCharacter')] public function testLikeCaseInsensitiveWithMultibyteCharacter(string $match, string $result): void { diff --git a/tests/system/Database/Live/MySQLi/RawSqlTest.php b/tests/system/Database/Live/MySQLi/RawSqlTest.php index b14f8d4d400d..a5a2bb9eb614 100644 --- a/tests/system/Database/Live/MySQLi/RawSqlTest.php +++ b/tests/system/Database/Live/MySQLi/RawSqlTest.php @@ -17,7 +17,6 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; -use stdClass; use Tests\Support\Database\Seeds\CITestSeeder; /** @@ -76,33 +75,6 @@ public function testRawSqlUpdateObject(): void $this->seeInDatabase('user', ['email' => 'ahmadinejad@world.com', 'created_at' => '2022-01-11 01:01:11']); } - /** - * @deprecated This test covers the deprecated setUpdateBatch() method. - */ - public function testRawSqlSetUpdateObject(): void - { - $data = []; - - $row = new stdClass(); - $row->email = 'derek@world.com'; - $row->created_at = new RawSql("setDateTime('2022-02-01')"); - $data[] = $row; - - $row = new stdClass(); - $row->email = 'ahmadinejad@world.com'; - $row->created_at = new RawSql("setDateTime('2022-02-01')"); - $data[] = $row; - - $this->db->table('user')->setUpdateBatch($data, 'email')->updateBatch(null, 'email'); - - $row->created_at = new RawSql("setDateTime('2022-02-11')"); - - $this->db->table('user')->set($row)->update(null, "email = 'ahmadinejad@world.com'"); - - $this->seeInDatabase('user', ['email' => 'derek@world.com', 'created_at' => '2022-02-01 01:01:11']); - $this->seeInDatabase('user', ['email' => 'ahmadinejad@world.com', 'created_at' => '2022-02-11 01:01:11']); - } - public function testRawSqlUpdateArray(): void { $this->db->table('user')->updateBatch([ @@ -174,46 +146,4 @@ public function testRawSqlInsertObject(): void $this->seeInDatabase('user', ['email' => 'sara@world.com', 'created_at' => '2022-05-01 01:01:11']); $this->seeInDatabase('user', ['email' => 'jessica@world.com', 'created_at' => '2022-05-11 01:01:11']); } - - /** - * @deprecated This test covers the deprecated setInsertBatch() method. - */ - public function testRawSqlSetInsertObject(): void - { - $data = []; - - $row = new stdClass(); - $row->name = 'Laura Palmer'; - $row->email = 'laura@world.com'; - $row->country = 'US'; - $row->created_at = new RawSql("setDateTime('2022-06-01')"); - $data[] = $row; - - $row = new stdClass(); - $row->name = 'Travis Touchdown'; - $row->email = 'travis@world.com'; - $row->country = 'US'; - $row->created_at = new RawSql("setDateTime('2022-06-01')"); - $data[] = $row; - - $this->db->table('user')->setInsertBatch($data)->insertBatch(); - $this->seeInDatabase('user', ['email' => 'laura@world.com', 'created_at' => '2022-06-01 01:01:11']); - $this->seeInDatabase('user', ['email' => 'travis@world.com', 'created_at' => '2022-06-01 01:01:11']); - - $row = new stdClass(); - $row->name = 'Steve Rogers'; - $row->email = 'steve@world.com'; - $row->country = 'US'; - $row->created_at = new RawSql("setDateTime('2022-06-11')"); - $this->db->table('user')->set($row)->insert(); - $this->seeInDatabase('user', ['email' => 'steve@world.com', 'created_at' => '2022-06-11 01:01:11']); - - $this->db->table('user') - ->set('name', 'Dan Brown') - ->set('email', 'dan@world.com') - ->set('country', 'US') - ->set('created_at', new RawSql("setDateTime('2022-06-13')")) - ->insert(); - $this->seeInDatabase('user', ['email' => 'dan@world.com', 'created_at' => '2022-06-13 01:01:11']); - } } diff --git a/tests/system/Database/Live/OrderTest.php b/tests/system/Database/Live/OrderTest.php index 1451222b5ceb..ec19e493f32e 100644 --- a/tests/system/Database/Live/OrderTest.php +++ b/tests/system/Database/Live/OrderTest.php @@ -92,6 +92,6 @@ public function testOrderRandom(): void $expected = 'SELECT * FROM ' . $table . ' ORDER BY ' . $key; - $this->assertSame($expected, str_replace("\n", ' ', $sql)); + $this->assertSameSql($expected, $sql); } } diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index 304269969a2d..4599bbcaba20 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BasePreparedQuery; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\Query; use CodeIgniter\Database\ResultInterface; use CodeIgniter\Exceptions\BadMethodCallException; @@ -22,6 +23,7 @@ use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Database\Seeds\CITestSeeder; +use Tests\Support\Enum\StatusEnum; /** * @internal @@ -222,14 +224,33 @@ public function testExecuteRunsQueryAndReturnsFalse(): void $this->disableDBDebug(); $this->assertTrue($this->query->execute('foo1', 'bar')); + $this->assertNotInstanceOf(DatabaseException::class, $this->db->getLastException()); + $this->assertFalse($this->query->execute('foo1', 'baz')); + $exception = $this->db->getLastException(); + $this->assertInstanceOf(UniqueConstraintViolationException::class, $exception); + $this->enableDBDebug(); $this->seeInDatabase($this->db->DBPrefix . 'without_auto_increment', ['key' => 'foo1', 'value' => 'bar']); $this->dontSeeInDatabase($this->db->DBPrefix . 'without_auto_increment', ['key' => 'foo1', 'value' => 'baz']); } + public function testExecuteThrowsUniqueConstraintViolationException(): void + { + $this->query = $this->db->prepare(static fn ($db) => $db->table('without_auto_increment')->insert([ + 'key' => 'a', + 'value' => 'b', + ])); + + $this->assertTrue($this->query->execute('foo1', 'bar')); + + $this->expectException(UniqueConstraintViolationException::class); + + $this->query->execute('foo1', 'baz'); + } + public function testExecuteRunsQueryManualAndReturnsFalse(): void { $this->query = $this->db->prepare(static function ($db): Query { @@ -266,6 +287,19 @@ public function testExecuteSelectQueryAndCheckTypeAndResult(): void $this->assertSame($expectedRow, $result->getRowArray()); } + public function testExecuteWithBackedEnum(): void + { + $this->query = $this->db->prepare(static fn ($db) => $db->table('team_members') + ->select('person_id') + ->where('status', StatusEnum::PENDING) + ->get()); + + $result = $this->query->execute(StatusEnum::ACTIVE); + + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertCount(2, $result->getResultArray()); + } + public function testExecuteSelectQueryManualAndCheckTypeAndResult(): void { $this->query = $this->db->prepare(static function ($db): Query { diff --git a/tests/system/Database/Live/TransactionCallbacksTest.php b/tests/system/Database/Live/TransactionCallbacksTest.php new file mode 100644 index 000000000000..2d7a6f0d3f31 --- /dev/null +++ b/tests/system/Database/Live/TransactionCallbacksTest.php @@ -0,0 +1,293 @@ + + * + * 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\Exceptions\DatabaseException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class TransactionCallbacksTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function setUp(): void + { + // Reset connection instance. + $this->db = Database::connect($this->DBGroup, false); + + parent::setUp(); + } + + public function testAfterCommitRunsImmediatelyWhenNoTransactionIsActive(): void + { + $callbacks = []; + + $result = $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'ran'; + }); + + $this->assertSame($this->db, $result); + $this->assertSame(['ran'], $callbacks); + } + + public function testAfterCommitRunsAfterSuccessfulTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['committed'], $callbacks); + } + + public function testAfterCommitRunsAfterManualTransactionCommit(): void + { + $callbacks = []; + + $this->db->transBegin(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transCommit(); + + $this->assertSame(['committed'], $callbacks); + } + + public function testAfterCommitDoesNotRunAfterTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(true); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + } + + public function testAfterCommitRunsAfterOutermostTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'outer'; + }); + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'inner'; + }); + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['outer', 'inner'], $callbacks); + } + + public function testAfterCommitCallbackExceptionBubblesAfterTransactionCommit(): void + { + $builder = $this->db->table('job'); + + $this->db->transStart(); + $builder->insert([ + 'name' => 'Committed Job', + 'description' => 'The transaction should still commit.', + ]); + $this->db->afterCommit(static function (): void { + throw new RuntimeException('Commit callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected commit callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Commit callback failed.', $e->getMessage()); + } + + $this->seeInDatabase('job', ['name' => 'Committed Job']); + } + + public function testAfterRollbackDoesNotRunWhenNoTransactionIsActive(): void + { + $callbacks = []; + + $result = $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame($this->db, $result); + $this->assertSame([], $callbacks); + } + + public function testAfterRollbackRunsAfterTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(true); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['rolled back'], $callbacks); + } + + public function testAfterRollbackRunsAfterManualTransactionRollback(): void + { + $callbacks = []; + + $this->db->transBegin(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transRollback(); + + $this->assertSame(['rolled back'], $callbacks); + } + + public function testAfterRollbackDoesNotRunAfterSuccessfulTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + } + + public function testAfterRollbackRunsAfterOutermostTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'outer'; + }); + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'inner'; + }); + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + + $this->db->transRollback(); + + $this->assertSame(['outer', 'inner'], $callbacks); + } + + public function testAfterRollbackCallbackExceptionBubblesAfterTransactionRollback(): void + { + $builder = $this->db->table('job'); + + $this->db->transStart(true); + $builder->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should still roll back.', + ]); + $this->db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } + + public function testAfterRollbackCallbackExceptionDoesNotPreventNonStrictStatusReset(): void + { + $this->db->transStrict(false); + $this->db->transStart(true); + $this->db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->assertTrue($this->db->transStatus()); + } + + public function testAfterRollbackRunsAfterAutomaticRollbackOnQueryFailure(): void + { + $callbacks = []; + $builder = $this->db->transException(true)->table('job'); + + try { + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + $builder->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should roll back.', + ]); + $builder->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + } catch (DatabaseException) { + // The framework already rolled back while handling the query failure. + } + + $this->assertSame(['rolled back'], $callbacks); + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } +} diff --git a/tests/system/Database/Live/TransactionClosureTest.php b/tests/system/Database/Live/TransactionClosureTest.php new file mode 100644 index 000000000000..a78b2fb429d6 --- /dev/null +++ b/tests/system/Database/Live/TransactionClosureTest.php @@ -0,0 +1,638 @@ + + * + * 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\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\RetryableTransactionException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class TransactionClosureTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function setUp(): void + { + // Reset connection instance. + $this->db = Database::connect($this->DBGroup, false); + + parent::setUp(); + } + + /** + * Sets $DBDebug to false. + */ + protected function disableDBDebug(): void + { + $this->setPrivateProperty($this->db, 'DBDebug', false); + } + + /** + * Sets $DBDebug to true. + */ + protected function enableDBDebug(): void + { + $this->setPrivateProperty($this->db, 'DBDebug', true); + } + + public function testTransactionReturnsCallbackResultAndCommits(): void + { + $result = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'name' => 'Committed Job', + 'description' => 'The transaction should commit.', + ]); + + return 'committed'; + }); + + $this->assertSame('committed', $result); + $this->seeInDatabase('job', ['name' => 'Committed Job']); + } + + public function testTransactionRetriesRetryableExceptionAndCommits(): void + { + $attempts = 0; + $callbacks = []; + + $result = $this->db->transaction(static function (BaseConnection $db) use (&$attempts, &$callbacks): string { + $attempts++; + + $db->table('job')->insert([ + 'name' => 'Retried Job ' . $attempts, + 'description' => 'Only the final attempt should commit.', + ]); + $db->afterCommit(static function () use (&$callbacks, &$attempts): void { + $callbacks[] = $attempts; + }); + + if ($attempts === 1) { + throw new RetryableTransactionException('Deadlock found when trying to get lock.', 1213); + } + + return 'committed'; + }, attempts: 2); + + $this->assertSame('committed', $result); + $this->assertSame(2, $attempts); + $this->assertSame([2], $callbacks); + $this->dontSeeInDatabase('job', ['name' => 'Retried Job 1']); + $this->seeInDatabase('job', ['name' => 'Retried Job 2']); + } + + public function testTransactionScopedTransExceptionRestoresAfterRetryAttempts(): void + { + $attempts = 0; + + $result = $this->db->transaction(static function () use (&$attempts): string { + $attempts++; + + if ($attempts === 1) { + throw new RetryableTransactionException('Deadlock found when trying to get lock.', 1213); + } + + return 'committed'; + }, attempts: 2, transException: true); + + $this->assertSame('committed', $result); + $this->assertSame(2, $attempts); + $this->assertFalse($this->getPrivateProperty($this->db, 'transException')); + } + + public function testRollbackCallbacksRunForFailedRetryAttempts(): void + { + $attempts = 0; + $callbacks = []; + + $result = $this->db->transaction(static function (BaseConnection $db) use (&$attempts, &$callbacks): string { + $attempts++; + + $db->afterRollback(static function () use (&$callbacks, &$attempts): void { + $callbacks[] = $attempts; + }); + + if ($attempts === 1) { + throw new RetryableTransactionException('Deadlock found when trying to get lock.', 1213); + } + + return 'committed'; + }, attempts: 2); + + $this->assertSame('committed', $result); + $this->assertSame(2, $attempts); + $this->assertSame([1], $callbacks); + } + + public function testRollbackCallbackExceptionStopsRetryAttempts(): void + { + $attempts = 0; + + try { + $this->db->transaction(static function (BaseConnection $db) use (&$attempts): void { + $attempts++; + + $db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + throw new RetryableTransactionException('Deadlock found when trying to get lock.', 1213); + }, attempts: 2); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->assertSame(1, $attempts); + } + + public function testTransactionRethrowsRetryableExceptionAfterAttemptsAreExhausted(): void + { + $attempts = 0; + + try { + $this->db->transaction(static function (BaseConnection $db) use (&$attempts): void { + $attempts++; + + $db->table('job')->insert([ + 'name' => 'Rolled Back Retried Job ' . $attempts, + 'description' => 'Every attempt should roll back.', + ]); + + throw new RetryableTransactionException('Deadlock found when trying to get lock.', 1213); + }, attempts: 2); + $this->fail('Expected retryable transaction exception.'); + } catch (RetryableTransactionException $e) { + $this->assertSame('Deadlock found when trying to get lock.', $e->getMessage()); + } + + $this->assertSame(2, $attempts); + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Retried Job 1']); + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Retried Job 2']); + } + + public function testTransactionDoesNotRetryNonRetryableException(): void + { + $attempts = 0; + + try { + $this->db->transaction(static function () use (&$attempts): void { + $attempts++; + + throw new RuntimeException('Transaction callback failed.'); + }, attempts: 3); + $this->fail('Expected transaction callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Transaction callback failed.', $e->getMessage()); + } + + $this->assertSame(1, $attempts); + } + + public function testTransactionPassesConnectionToCallback(): void + { + $result = $this->db->transaction(fn (BaseConnection $db): bool => $db === $this->db); + + $this->assertTrue($result); + } + + public function testTransactionRollsBackAndRethrowsCallbackException(): void + { + try { + $this->db->transaction(static function (BaseConnection $db): void { + $db->table('job')->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should roll back.', + ]); + + throw new RuntimeException('Transaction callback failed.'); + }); + $this->fail('Expected transaction callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Transaction callback failed.', $e->getMessage()); + } + + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } + + public function testTransactionReturnsFalseAndRollsBackWhenTransactionStatusFails(): void + { + $this->disableDBDebug(); + + $result = $this->db->transaction(static function (BaseConnection $db): string { + $builder = $db->table('job'); + + $builder->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should roll back.', + ]); + $builder->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + + return 'not returned'; + }); + + $this->assertFalse($result); + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + + $this->enableDBDebug(); + } + + public function testTransactionScopedTransExceptionRestoresAfterSuccess(): void + { + $result = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'name' => 'Scoped Exception Mode Job', + 'description' => 'The transaction should commit.', + ]); + + return 'committed'; + }, transException: true); + + $this->assertSame('committed', $result); + $this->assertFalse($this->getPrivateProperty($this->db, 'transException')); + } + + public function testTransactionScopedTransExceptionRestoresAfterQueryFailure(): void + { + try { + $this->db->transaction(static function (BaseConnection $db): void { + $db->table('job')->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + }, transException: true); + $this->fail('Expected database exception.'); + } catch (DatabaseException) { + // The scoped transaction exception mode should be restored after failure. + } + + $this->assertFalse($this->getPrivateProperty($this->db, 'transException')); + } + + public function testTransactionScopedTransExceptionCanTemporarilyDisableExistingMode(): void + { + $this->db->transException(true); + + $result = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + + return 'not returned'; + }, transException: false); + + $this->assertFalse($result); + $this->assertTrue($this->getPrivateProperty($this->db, 'transException')); + } + + public function testTransactionResetTransStatusRestartsAfterStrictModeFailure(): void + { + $this->disableDBDebug(); + + $failed = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + + return 'not returned'; + }); + + $this->assertFalse($failed); + $this->assertFalse($this->db->transStatus()); + + $result = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'name' => 'Restarted Job', + 'description' => 'The transaction should commit.', + ]); + + return 'committed'; + }, resetTransStatus: true); + + $this->assertSame('committed', $result); + $this->assertTrue($this->db->transStatus()); + $this->seeInDatabase('job', ['name' => 'Restarted Job']); + + $this->enableDBDebug(); + } + + public function testTransactionWithoutResetTransStatusPreservesStrictModeFailure(): void + { + $this->disableDBDebug(); + + $failed = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + + return 'not returned'; + }); + + $this->assertFalse($failed); + + $result = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'name' => 'Still Rolled Back Job', + 'description' => 'The strict-mode failure should still apply.', + ]); + + return 'not returned'; + }); + + $this->assertFalse($result); + $this->dontSeeInDatabase('job', ['name' => 'Still Rolled Back Job']); + + $this->enableDBDebug(); + } + + public function testTransactionCallbackExceptionDoesNotPreventNonStrictStatusReset(): void + { + $this->disableDBDebug(); + $this->db->transStrict(false); + + try { + $this->db->transaction(static function (BaseConnection $db): void { + $db->table('job')->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + + throw new RuntimeException('Transaction callback failed.'); + }); + $this->fail('Expected transaction callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Transaction callback failed.', $e->getMessage()); + } + + $this->assertTrue($this->db->transStatus()); + + $this->enableDBDebug(); + } + + public function testTransactionRunsAfterCommitCallbacksAfterSuccessfulCommit(): void + { + $callbacks = []; + + $result = $this->db->transaction(static function (BaseConnection $db) use (&$callbacks): string { + $db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $db->table('job')->insert([ + 'name' => 'Committed Job', + 'description' => 'The transaction should commit.', + ]); + + return 'committed'; + }); + + $this->assertSame('committed', $result); + $this->assertSame(['committed'], $callbacks); + } + + public function testTransactionRunsAfterRollbackCallbacksAfterCallbackException(): void + { + $callbacks = []; + + try { + $this->db->transaction(static function (BaseConnection $db) use (&$callbacks): void { + $db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + throw new RuntimeException('Transaction callback failed.'); + }); + $this->fail('Expected transaction callback exception.'); + } catch (RuntimeException) { + // The rollback callback should have already run. + } + + $this->assertSame(['rolled back'], $callbacks); + } + + public function testRollbackCallbackExceptionBubblesWhenCallbackExceptionTriggersRollback(): void + { + try { + $this->db->transaction(static function (BaseConnection $db): void { + $db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + $db->table('job')->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should roll back.', + ]); + + throw new RuntimeException('Transaction callback failed.'); + }); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->assertLogContains('error', 'Transaction callback failed.'); + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } + + public function testNestedTransactionCallbacksRunAfterOutermostCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + + $result = $this->db->transaction(static function (BaseConnection $db) use (&$callbacks): string { + $db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + return 'nested'; + }); + + $this->assertSame('nested', $result); + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['committed'], $callbacks); + } + + public function testNestedTransactionWritesCommitAfterOutermostCommit(): void + { + $this->db->transStart(); + + $this->db->table('job')->insert([ + 'name' => 'Outer Job', + 'description' => 'The outer transaction should commit.', + ]); + + $result = $this->db->transaction(static function (BaseConnection $db): string { + $db->table('job')->insert([ + 'name' => 'Inner Job', + 'description' => 'The nested transaction should commit.', + ]); + + return 'nested'; + }); + + $this->assertSame('nested', $result); + + $this->db->transComplete(); + + $this->seeInDatabase('job', ['name' => 'Outer Job']); + $this->seeInDatabase('job', ['name' => 'Inner Job']); + } + + public function testNestedTransactionCallbackExceptionMarksOuterTransactionForRollback(): void + { + $this->db->transStart(); + + $this->db->table('job')->insert([ + 'name' => 'Outer Job', + 'description' => 'The outer transaction should roll back.', + ]); + + try { + $this->db->transaction(static function (BaseConnection $db): void { + $db->table('job')->insert([ + 'name' => 'Inner Job', + 'description' => 'The nested transaction should roll back.', + ]); + + throw new RuntimeException('Nested transaction callback failed.'); + }); + $this->fail('Expected nested transaction callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Nested transaction callback failed.', $e->getMessage()); + } + + $this->assertFalse($this->db->transStatus()); + + $this->db->transComplete(); + + $this->dontSeeInDatabase('job', ['name' => 'Outer Job']); + $this->dontSeeInDatabase('job', ['name' => 'Inner Job']); + } + + public function testNestedTransactionResetTransStatusDoesNotClearOuterFailure(): void + { + $this->disableDBDebug(); + + $this->db->transStart(); + $this->db->table('job')->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + + $result = $this->db->transaction(static fn (): string => 'not returned', resetTransStatus: true); + + $this->assertFalse($result); + $this->assertFalse($this->db->transStatus()); + + $this->db->transComplete(); + + $this->enableDBDebug(); + } + + public function testNestedTransactionScopedTransExceptionQueryFailureRollsBackOuterTransaction(): void + { + $this->db->transStart(); + $this->db->table('job')->insert([ + 'name' => 'Outer Job', + 'description' => 'The outer transaction should roll back.', + ]); + + try { + $this->db->transaction(static function (BaseConnection $db): void { + $db->table('job')->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + }, transException: true); + $this->fail('Expected database exception.'); + } catch (DatabaseException) { + // Existing transaction exception handling rolls back the full stack. + } + + $this->assertSame(0, $this->db->transDepth); + $this->dontSeeInDatabase('job', ['name' => 'Outer Job']); + } + + public function testNestedTransactionRetryAttemptsRunOnce(): void + { + $attempts = 0; + + $this->db->transStart(); + + try { + $this->db->transaction(static function () use (&$attempts): void { + $attempts++; + + throw new RetryableTransactionException('Deadlock found when trying to get lock.', 1213); + }, attempts: 3); + $this->fail('Expected retryable transaction exception.'); + } catch (RetryableTransactionException) { + // Nested transactions cannot be safely replayed without savepoints. + } + + $this->assertSame(1, $attempts); + $this->assertFalse($this->db->transStatus()); + + $this->db->transComplete(); + } + + public function testAfterCommitCallbackExceptionBubblesAfterTransactionCommit(): void + { + try { + $this->db->transaction(static function (BaseConnection $db): void { + $db->table('job')->insert([ + 'name' => 'Committed Job', + 'description' => 'The transaction should still commit.', + ]); + $db->afterCommit(static function (): void { + throw new RuntimeException('Commit callback failed.'); + }); + }); + $this->fail('Expected commit callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Commit callback failed.', $e->getMessage()); + } + + $this->seeInDatabase('job', ['name' => 'Committed Job']); + } +} diff --git a/tests/system/Database/Live/UniqueConstraintViolationTest.php b/tests/system/Database/Live/UniqueConstraintViolationTest.php new file mode 100644 index 000000000000..0a436f079b88 --- /dev/null +++ b/tests/system/Database/Live/UniqueConstraintViolationTest.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\Database\Live; + +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class UniqueConstraintViolationTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function tearDown(): void + { + $this->enableDBDebug(); + + parent::tearDown(); + } + + public function testThrowsUniqueConstraintViolationExceptionWithDebugEnabled(): void + { + $this->enableDBDebug(); + + $this->expectException(UniqueConstraintViolationException::class); + + // 'derek@world.com' is already seeded in the user table + $this->db->table('user')->insert([ + 'name' => 'Duplicate', + 'email' => 'derek@world.com', + 'country' => 'US', + ]); + } + + public function testReturnsFalseAndErrorIsPopulatedWithDebugDisabled(): void + { + $this->disableDBDebug(); + + // 'derek@world.com' is already seeded in the user table + $result = $this->db->table('user')->insert([ + 'name' => 'Duplicate', + 'email' => 'derek@world.com', + 'country' => 'US', + ]); + + $this->assertFalse($result); + + $error = $this->db->error(); + + $expectedCode = match ($this->db->DBDriver) { + 'MySQLi' => 1062, + 'Postgre' => '23505', + 'SQLite3' => 19, + 'SQLSRV' => '23000/2627', + 'OCI8' => 1, + default => $this->fail('No expected error code defined for DB driver: ' . $this->db->DBDriver), + }; + + $this->assertSame($expectedCode, $error['code']); + $this->assertNotEmpty($error['message']); + + $exception = $this->db->getLastException(); + $this->assertInstanceOf(UniqueConstraintViolationException::class, $exception); + $this->assertSame($expectedCode, $exception->getDatabaseCode()); + } +} diff --git a/tests/system/Database/Live/WorkerModeTest.php b/tests/system/Database/Live/WorkerModeTest.php index a8c77d756da7..276f432758ab 100644 --- a/tests/system/Database/Live/WorkerModeTest.php +++ b/tests/system/Database/Live/WorkerModeTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; +use RuntimeException; /** * @internal @@ -48,6 +49,22 @@ public function testCleanupForWorkerMode(): void $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); } + public function testCleanupForWorkerModeLogsRollbackCallbackException(): void + { + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + + $conn->transStart(); + $conn->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + Config::cleanupForWorkerMode(); + + $this->assertSame(0, $conn->transDepth); + $this->assertLogged('critical', 'Rollback callback failed.'); + } + public function testReconnectForWorkerMode(): void { $conn = Config::connect(); diff --git a/tests/system/Database/RetryableTransactionExceptionTest.php b/tests/system/Database/RetryableTransactionExceptionTest.php new file mode 100644 index 000000000000..a78f15d20142 --- /dev/null +++ b/tests/system/Database/RetryableTransactionExceptionTest.php @@ -0,0 +1,290 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\RetryableTransactionException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; +use CodeIgniter\Database\OCI8\Connection as OCI8Connection; +use CodeIgniter\Database\Postgre\Connection as PostgreConnection; +use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection; +use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection; +use CodeIgniter\Events\Events; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use ErrorException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Mock\MockPreparedQuery; + +/** + * @internal + */ +#[Group('Others')] +final class RetryableTransactionExceptionTest extends CIUnitTestCase +{ + #[DataProvider('provideCreatesUniqueConstraintViolationExceptions')] + public function testCreatesUniqueConstraintViolationExceptions( + BaseConnection $db, + int|string $code, + string $message, + ): void { + $exception = self::createDatabaseException($db, $message, $code); + + $this->assertInstanceOf(UniqueConstraintViolationException::class, $exception); + $this->assertSame($code, $exception->getDatabaseCode()); + } + + /** + * @return iterable + */ + public static function provideCreatesUniqueConstraintViolationExceptions(): iterable + { + yield 'MySQLi duplicate key' => [ + self::connection(MySQLiConnection::class, 'MySQLi'), + 1062, + 'Duplicate entry.', + ]; + + yield 'Postgre unique violation' => [ + self::connection(PostgreConnection::class, 'Postgre'), + '23505', + 'Unique violation.', + ]; + + yield 'SQLite unique constraint' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'UNIQUE constraint failed: table.column', + ]; + + yield 'SQLite legacy unique constraint' => [ + self::connection(SQLite3Connection::class, 'SQLite3'), + 19, + 'column email is not unique', + ]; + + yield 'SQLSRV unique constraint' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000/2627', + 'Violation of UNIQUE KEY constraint.', + ]; + + yield 'SQLSRV unique index' => [ + self::connection(SQLSRVConnection::class, 'SQLSRV'), + '23000/2601', + 'Cannot insert duplicate key row.', + ]; + + if (defined('OCI_COMMIT_ON_SUCCESS')) { + yield 'OCI8 unique constraint' => [ + self::connection(OCI8Connection::class, 'OCI8'), + 1, + 'Unique constraint violated.', + ]; + + yield 'OCI8 unique constraint string code' => [ + self::connection(OCI8Connection::class, 'OCI8'), + '1', + 'Unique constraint violated.', + ]; + } + } + + #[DataProvider('provideCreatesRetryableTransactionExceptions')] + public function testCreatesRetryableTransactionExceptions(BaseConnection $db, int|string $code): void + { + $exception = self::createDatabaseException($db, 'Retryable transaction failure.', $code); + + $this->assertInstanceOf(RetryableTransactionException::class, $exception); + $this->assertSame($code, $exception->getDatabaseCode()); + } + + /** + * @return iterable + */ + public static function provideCreatesRetryableTransactionExceptions(): iterable + { + yield 'MySQLi deadlock' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1213]; + + yield 'Postgre serialization failure' => [self::connection(PostgreConnection::class, 'Postgre'), '40001']; + + yield 'Postgre deadlock' => [self::connection(PostgreConnection::class, 'Postgre'), '40P01']; + + yield 'SQLite busy' => [self::connection(SQLite3Connection::class, 'SQLite3'), 5]; + + yield 'SQLSRV deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001/1205']; + + yield 'SQLSRV vendor deadlock' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 1205]; + + yield 'SQLSRV snapshot isolation conflict' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HY000/3960']; + + if (defined('OCI_COMMIT_ON_SUCCESS')) { + yield 'OCI8 deadlock' => [self::connection(OCI8Connection::class, 'OCI8'), 60]; + + yield 'OCI8 deadlock string code' => [self::connection(OCI8Connection::class, 'OCI8'), '60']; + + yield 'OCI8 serialization failure' => [self::connection(OCI8Connection::class, 'OCI8'), 8177]; + + yield 'OCI8 serialization failure string code' => [self::connection(OCI8Connection::class, 'OCI8'), '8177']; + } + } + + #[DataProvider('provideCreatesBaseDatabaseExceptionsForNonRetryableErrors')] + public function testCreatesBaseDatabaseExceptionsForNonRetryableErrors(BaseConnection $db, int|string $code): void + { + $exception = self::createDatabaseException($db, 'Non-retryable transaction failure.', $code); + + $this->assertNotInstanceOf(RetryableTransactionException::class, $exception); + } + + /** + * @return iterable + */ + public static function provideCreatesBaseDatabaseExceptionsForNonRetryableErrors(): iterable + { + yield 'Base connection default' => [self::connection(MockConnection::class, 'MockDriver'), 1213]; + + yield 'MySQLi lock wait timeout' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1205]; + + yield 'MySQLi duplicate key' => [self::connection(MySQLiConnection::class, 'MySQLi'), 1062]; + + yield 'Postgre unique violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23505']; + + yield 'Postgre exclusion violation' => [self::connection(PostgreConnection::class, 'Postgre'), '23P01']; + + yield 'SQLite locked' => [self::connection(SQLite3Connection::class, 'SQLite3'), 6]; + + yield 'SQLite busy snapshot extended code' => [self::connection(SQLite3Connection::class, 'SQLite3'), 517]; + + yield 'SQLite constraint' => [self::connection(SQLite3Connection::class, 'SQLite3'), 19]; + + yield 'SQLSRV lock timeout' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), 'HYT00/1222']; + + yield 'SQLSRV SQLSTATE without vendor code' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '40001']; + + yield 'SQLSRV unique constraint' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2627']; + + yield 'SQLSRV unique index' => [self::connection(SQLSRVConnection::class, 'SQLSRV'), '23000/2601']; + + if (defined('OCI_COMMIT_ON_SUCCESS')) { + yield 'OCI8 resource busy' => [self::connection(OCI8Connection::class, 'OCI8'), 54]; + + yield 'OCI8 unique constraint' => [self::connection(OCI8Connection::class, 'OCI8'), 1]; + } + } + + public function testQueryThrowsRetryableTransactionExceptionFromDriverExecutionPath(): void + { + $db = $this->getMockBuilder(MySQLiConnection::class) + ->setConstructorArgs([self::config('MySQLi')]) + ->onlyMethods(['connect', 'execute']) + ->getMock(); + + $db->method('connect')->willReturn(mysqli_init()); + $db->method('execute')->willThrowException( + self::createDatabaseException($db, 'Deadlock found when trying to get lock.', 1213), + ); + + $this->expectException(RetryableTransactionException::class); + + $db->query('SELECT * FROM test'); + } + + public function testPreparedQueryThrowsRetryableTransactionExceptionFromBaseExecutionPath(): void + { + $preparedQuery = new MockPreparedQuery(self::connection(MySQLiConnection::class, 'MySQLi')); + $preparedQuery->thrownException = new ErrorException('Deadlock found when trying to get lock.', 1213); + + $preparedQuery->prepare('SELECT 1'); + + $this->expectException(RetryableTransactionException::class); + + $preparedQuery->execute(); + } + + public function testPreparedQueryRoutesDriverDatabaseExceptionThroughBaseExecutionPath(): void + { + $db = self::connection(MySQLiConnection::class, 'MySQLi'); + $preparedQuery = new MockPreparedQuery($db); + $preparedQuery->thrownException = self::createDatabaseException($db, 'Deadlock found when trying to get lock.', 1213); + $queryCount = 0; + $listener = static function () use (&$queryCount): void { + $queryCount++; + }; + + $preparedQuery->prepare('SELECT 1'); + Events::on('DBQuery', $listener); + + try { + $preparedQuery->execute(); + $this->fail('Expected retryable transaction exception was not thrown.'); + } catch (RetryableTransactionException $e) { + $this->assertSame($preparedQuery->thrownException, $e); + } finally { + Events::removeListener('DBQuery', $listener); + } + + $this->assertSame(1, $queryCount); + } + + public function testPreparedQueryStoresRetryableTransactionExceptionWithDebugDisabled(): void + { + $db = new MySQLiConnection(self::config('MySQLi', false)); + + $preparedQuery = new MockPreparedQuery($db); + $preparedQuery->thrownException = new ErrorException('Deadlock found when trying to get lock.', 1213); + + $preparedQuery->prepare('SELECT 1'); + + $this->assertFalse($preparedQuery->execute()); + $this->assertInstanceOf(RetryableTransactionException::class, $db->getLastException()); + } + + /** + * @param class-string $connectionClass + */ + private static function connection(string $connectionClass, string $driver): BaseConnection + { + return new $connectionClass(self::config($driver)); + } + + /** + * @return array + */ + private static function config(string $driver, bool $debug = true): array + { + return [ + 'DSN' => '', + 'hostname' => 'localhost', + 'username' => '', + 'password' => '', + 'database' => 'test', + 'DBDriver' => $driver, + 'DBDebug' => $debug, + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'failover' => [], + ]; + } + + private static function createDatabaseException(BaseConnection $db, string $message, int|string $code): DatabaseException + { + return $db->createDatabaseException($message, $code); + } +} diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index 43086860b0d6..0b44a9d83b23 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -16,9 +16,13 @@ use App\Controllers\Home; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\IniTestTrait; use CodeIgniter\Test\StreamFilterTrait; +use Config\App; use Config\Exceptions as ExceptionsConfig; use Config\Services; use PHPUnit\Framework\Attributes\Group; @@ -104,6 +108,65 @@ public function testCollectVars(): void foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) { $this->assertArrayHasKey($key, $vars); } + + $this->assertArrayNotHasKey('copyableErrorReport', $vars); + } + + public function testCopyErrorReportIncludesPreviousExceptions(): void + { + $previous = new RuntimeException('Root cause.'); + $exception = new RuntimeException('Top level.', 0, $previous); + + $report = $this->extractCopyableErrorReport($this->renderHtmlException($exception)); + + $this->assertStringContainsString('## Previous Exceptions', $report); + $this->assertStringContainsString('* CodeIgniter\Exceptions\RuntimeException - Root cause.', $report); + } + + public function testCopyErrorReportOmitsSensitiveRequestDataAndTraceArgs(): void + { + $exception = $this->createExceptionWithSensitiveTraceArgument(); + + $_COOKIE['debug_cookie'] = 'cookie-secret'; + $_POST['debug_post'] = 'post-secret'; + + try { + $report = $this->extractCopyableErrorReport($this->renderHtmlException($exception)); + + $this->assertStringNotContainsString('secret-token', $report); + $this->assertStringNotContainsString('cookie-secret', $report); + $this->assertStringNotContainsString('post-secret', $report); + $this->assertStringNotContainsString('$_COOKIE', $report); + $this->assertStringNotContainsString('$_POST', $report); + } finally { + unset($_COOKIE['debug_cookie'], $_POST['debug_post']); + } + } + + public function testCopyErrorReportOmitsQueryStringFromUrl(): void + { + $config = new App(); + $secret = 'query-secret'; + $token = '?token='; + $request = new IncomingRequest( + $config, + new SiteURI($config, '/orders?token=' . $secret, 'example.test', 'https'), + null, + new UserAgent(), + ); + + Services::injectMock('request', $request); + + try { + $report = $this->extractCopyableErrorReport($this->renderHtmlException(new RuntimeException('Query test.'))); + + $this->assertStringContainsString('- Path: /orders', $report); + $this->assertStringContainsString('- URL: https://example.test/orders', $report); + $this->assertStringNotContainsString($secret, $report); + $this->assertStringNotContainsString($token, $report); + } finally { + $this->resetServices(); + } } public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void @@ -141,6 +204,27 @@ public function testHandleWebPageNotFoundExceptionAcceptHTML(): void $this->assertStringContainsString('404 - Page Not Found', (string) $output); } + public function testHandleWebRuntimeExceptionAcceptHTMLIncludesCopyErrorReport(): void + { + $output = $this->renderHtmlException(new RuntimeException('Something went wrong.')); + $report = $this->extractCopyableErrorReport($output); + + $this->assertStringContainsString('Copy Details', $output); + $this->assertStringContainsString('# Something went wrong.', $report); + + foreach (['## Exception', '## Environment', '## Request', '## Source', '## Stack Trace'] as $section) { + $this->assertStringContainsString($section, $report); + } + } + + public function testHandleWebRuntimeExceptionEscapesCopyErrorReport(): void + { + $output = $this->renderHtmlException(new RuntimeException('')); + + $this->assertStringNotContainsString('', $output); + $this->assertStringContainsString('</textarea><script>alert(1)</script>', $output); + } + public function testHandleCLIPageNotFoundException(): void { $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); @@ -385,4 +469,35 @@ public function testSanitizeDataWithScalars(): void $this->assertFalse($sanitizeData(false)); $this->assertNull($sanitizeData(null)); } + + private function createExceptionWithSensitiveTraceArgument(): RuntimeException + { + return new RuntimeException('Trace argument test.'); + } + + private function extractCopyableErrorReport(string $output): string + { + $this->assertSame(1, preg_match('#]*>\K.*?(?=)#s', $output, $matches)); + + return html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + private function renderHtmlException(RuntimeException $exception): string + { + $this->backupIniValues([ + 'highlight.comment', 'highlight.default', 'highlight.html', 'highlight.keyword', 'highlight.string', + ]); + + $render = self::getPrivateMethodInvoker($this->handler, 'render'); + + ob_start(); + + try { + $render($exception, 500, APPPATH . 'Views/errors/html/error_exception.php'); + + return ob_get_clean(); + } finally { + $this->restoreIniValues(); + } + } } diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index 919c1ea85180..38640812e0fc 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -11,11 +11,9 @@ namespace CodeIgniter\Debug; -use App\Controllers\Home; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\Exceptions\ConfigException; -use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ReflectionHelper; @@ -92,27 +90,6 @@ public function testSuppressedDeprecationsAreLogged(): void restore_exception_handler(); } - public function testDetermineViews(): void - { - $determineView = self::getPrivateMethodInvoker($this->exception, 'determineView'); - - $this->assertSame('error_404.php', $determineView(PageNotFoundException::forControllerNotFound('Foo', 'bar'), '')); - $this->assertSame('error_exception.php', $determineView(new RuntimeException('Exception'), '')); - $this->assertSame('error_404.php', $determineView(new RuntimeException('foo', 404), 'app/Views/errors/cli')); - } - - public function testCollectVars(): void - { - $vars = self::getPrivateMethodInvoker($this->exception, 'collectVars')(new RuntimeException('This.'), 404); - - $this->assertIsArray($vars); - $this->assertCount(7, $vars); - - foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) { - $this->assertArrayHasKey($key, $vars); - } - } - public function testDetermineCodes(): void { $determineCodes = self::getPrivateMethodInvoker($this->exception, 'determineCodes'); @@ -125,82 +102,4 @@ public function testDetermineCodes(): void $this->assertSame([500, EXIT_CONFIG], $determineCodes(CastException::forInvalidInterface('This.'))); $this->assertSame([500, EXIT_DATABASE], $determineCodes(new DatabaseException('This.'))); } - - public function testMaskSensitiveData(): void - { - $maskSensitiveData = self::getPrivateMethodInvoker($this->exception, 'maskSensitiveData'); - - $trace = [ - 0 => [ - 'file' => '/var/www/CodeIgniter4/app/Controllers/Home.php', - 'line' => 15, - 'function' => 'f', - 'class' => Home::class, - 'type' => '->', - 'args' => [ - 0 => (object) [ - 'password' => 'secret1', - ], - 1 => (object) [ - 'default' => [ - 'password' => 'secret2', - ], - ], - 2 => [ - 'password' => 'secret3', - ], - 3 => [ - 'default' => ['password' => 'secret4'], - ], - ], - ], - 1 => [ - 'file' => '/var/www/CodeIgniter4/system/CodeIgniter.php', - 'line' => 932, - 'function' => 'index', - 'class' => Home::class, - 'type' => '->', - 'args' => [], - ], - ]; - $keysToMask = ['password']; - $path = ''; - - $newTrace = $maskSensitiveData($trace, $keysToMask, $path); - - $this->assertSame(['password' => '******************'], (array) $newTrace[0]['args'][0]); - $this->assertSame(['password' => '******************'], $newTrace[0]['args'][1]->default); - $this->assertSame(['password' => '******************'], $newTrace[0]['args'][2]); - $this->assertSame(['password' => '******************'], $newTrace[0]['args'][3]['default']); - } - - public function testMaskSensitiveDataTraceDataKey(): void - { - $maskSensitiveData = self::getPrivateMethodInvoker($this->exception, 'maskSensitiveData'); - - $trace = [ - 0 => [ - 'file' => '/var/www/CodeIgniter4/app/Controllers/Home.php', - 'line' => 15, - 'function' => 'f', - 'class' => Home::class, - 'type' => '->', - 'args' => [], - ], - 1 => [ - 'file' => '/var/www/CodeIgniter4/system/CodeIgniter.php', - 'line' => 932, - 'function' => 'index', - 'class' => Home::class, - 'type' => '->', - 'args' => [], - ], - ]; - $keysToMask = ['file']; - $path = ''; - - $newTrace = $maskSensitiveData($trace, $keysToMask, $path); - - $this->assertSame('/var/www/CodeIgniter4/app/Controllers/Home.php', $newTrace[0]['file']); - } } diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 5233a28deeaf..5a74d239dd66 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -423,6 +423,72 @@ public function testCastFloat(): void $this->assertEqualsWithDelta(3.6, $entity->second, PHP_FLOAT_EPSILON); } + public function testCastFloatWithPrecision(): void + { + $entity = $this->getCastEntity(); + + $entity->fourteenth = 3.1415926535; + + $this->assertIsFloat($entity->fourteenth); + $this->assertEqualsWithDelta(3.14, $entity->fourteenth, PHP_FLOAT_EPSILON); + + $entity->fourteenth = '3.1415926535'; + + $this->assertIsFloat($entity->fourteenth); + $this->assertEqualsWithDelta(3.14, $entity->fourteenth, PHP_FLOAT_EPSILON); + } + + public function testCastFloatWithPrecisionAndRoundingMode(): void + { + $entity = $this->getCastEntity(); + + $entity->fifteenth = 3.145; + + $this->assertIsFloat($entity->fifteenth); + $this->assertEqualsWithDelta(3.14, $entity->fifteenth, PHP_FLOAT_EPSILON); + + $entity->fifteenth = '3.135'; + + $this->assertIsFloat($entity->fifteenth); + $this->assertEqualsWithDelta(3.13, $entity->fifteenth, PHP_FLOAT_EPSILON); + + $entity->sixteenth = 20.0005; + + $this->assertIsFloat($entity->sixteenth); + $this->assertEqualsWithDelta(20.000, $entity->sixteenth, PHP_FLOAT_EPSILON); + + $entity->sixteenth = '20.0005'; + + $this->assertIsFloat($entity->sixteenth); + $this->assertEqualsWithDelta(20.000, $entity->sixteenth, PHP_FLOAT_EPSILON); + + $entity->seventeenth = 1.25; + + $this->assertIsFloat($entity->seventeenth); + $this->assertEqualsWithDelta(1.3, $entity->seventeenth, PHP_FLOAT_EPSILON); + + $entity->seventeenth = '1.25'; + + $this->assertIsFloat($entity->seventeenth); + $this->assertEqualsWithDelta(1.3, $entity->seventeenth, PHP_FLOAT_EPSILON); + } + + public function testCastFloatWithInvalidRoundingMode(): void + { + $this->expectException(CastException::class); + $this->expectExceptionMessage('Invalid rounding mode "invalidMode" for float casting.'); + + $entity = new class () extends Entity { + protected $casts = [ + 'temp' => 'float[1,invalidMode]', + ]; + }; + + $entity->temp = '4.548'; + + $entity->temp; // @phpstan-ignore expr.resultUnused + } + public function testCastDouble(): void { $entity = $this->getCastEntity(); @@ -1780,19 +1846,23 @@ private function getCastEntity($data = null): object // 'bar' is db column, 'foo' is internal representation protected $casts = [ - 'first' => 'integer', - 'second' => 'float', - 'third' => 'double', - 'fourth' => 'string', - 'fifth' => 'boolean', - 'sixth' => 'object', - 'seventh' => 'array', - 'eighth' => 'datetime', - 'ninth' => 'timestamp', - 'tenth' => 'json', - 'eleventh' => 'json-array', - 'twelfth' => 'csv', - 'thirteenth' => 'uri', + 'first' => 'integer', + 'second' => 'float', + 'third' => 'double', + 'fourth' => 'string', + 'fifth' => 'boolean', + 'sixth' => 'object', + 'seventh' => 'array', + 'eighth' => 'datetime', + 'ninth' => 'timestamp', + 'tenth' => 'json', + 'eleventh' => 'json-array', + 'twelfth' => 'csv', + 'thirteenth' => 'uri', + 'fourteenth' => 'float[2]', + 'fifteenth' => 'float[2,down]', + 'sixteenth' => 'float[3,even]', + 'seventeenth' => 'float[1,odd]', ]; public function setSeventh(string $seventh): void diff --git a/tests/system/EnvironmentDetectorTest.php b/tests/system/EnvironmentDetectorTest.php new file mode 100644 index 000000000000..d4faa703883e --- /dev/null +++ b/tests/system/EnvironmentDetectorTest.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; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class EnvironmentDetectorTest extends CIUnitTestCase +{ + public function testDefaultsToEnvironmentConstant(): void + { + $detector = new EnvironmentDetector(); + + $this->assertSame(ENVIRONMENT, $detector->get()); + } + + public function testExplicitEnvironmentOverridesConstant(): void + { + $detector = new EnvironmentDetector('production'); + + $this->assertSame('production', $detector->get()); + } + + public function testTrimsSurroundingWhitespace(): void + { + $detector = new EnvironmentDetector(" production\n"); + + $this->assertSame('production', $detector->get()); + } + + public function testRejectsEmptyString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Environment cannot be an empty string.'); + + new EnvironmentDetector(''); // @phpstan-ignore argument.type + } + + public function testRejectsWhitespaceOnlyString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Environment cannot be an empty string.'); + + new EnvironmentDetector(" \t\n"); + } + + public function testIsMatchesSingleEnvironment(): void + { + $detector = new EnvironmentDetector('staging'); + + $this->assertTrue($detector->is('staging')); + $this->assertFalse($detector->is('production')); + } + + public function testIsMatchesAnyOfSeveralEnvironments(): void + { + $detector = new EnvironmentDetector('staging'); + + $this->assertTrue($detector->is('production', 'staging', 'development')); + $this->assertFalse($detector->is('production', 'development', 'testing')); + } + + public function testIsReturnsFalseWhenNoEnvironmentsGiven(): void + { + $detector = new EnvironmentDetector('production'); + + $this->assertFalse($detector->is()); + } + + public function testIsIsCaseSensitive(): void + { + $detector = new EnvironmentDetector('production'); + + $this->assertFalse($detector->is('Production')); + $this->assertFalse($detector->is('PRODUCTION')); + } + + /** + * @param non-empty-string $environment + */ + #[DataProvider('provideBuiltInEnvironmentHelpers')] + public function testBuiltInEnvironmentHelpers(string $environment, bool $isProduction, bool $isDevelopment, bool $isTesting): void + { + $detector = new EnvironmentDetector($environment); + + $this->assertSame($isProduction, $detector->isProduction()); + $this->assertSame($isDevelopment, $detector->isDevelopment()); + $this->assertSame($isTesting, $detector->isTesting()); + } + + /** + * @return iterable + */ + public static function provideBuiltInEnvironmentHelpers(): iterable + { + yield 'production' => ['production', true, false, false]; + + yield 'development' => ['development', false, true, false]; + + yield 'testing' => ['testing', false, false, true]; + + yield 'custom' => ['staging', false, false, false]; + } + + public function testResolvesAsSharedService(): void + { + $first = service('environment'); + $second = service('environment'); + + $this->assertInstanceOf(EnvironmentDetector::class, $first); + $this->assertSame($first, $second); + $this->assertSame(ENVIRONMENT, $first->get()); + } +} diff --git a/tests/system/Filters/CSRFTest.php b/tests/system/Filters/CSRFTest.php index d1977e4f5cea..f8d594a93ad8 100644 --- a/tests/system/Filters/CSRFTest.php +++ b/tests/system/Filters/CSRFTest.php @@ -13,11 +13,16 @@ namespace CodeIgniter\Filters; +use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Response; +use CodeIgniter\Security\Exceptions\SecurityException; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockSecurity; +use Config\Security as SecurityConfig; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; @@ -29,12 +34,7 @@ final class CSRFTest extends CIUnitTestCase { private \Config\Filters $config; - - /** - * @var CLIRequest|IncomingRequest|null - */ - private $request; - + private CLIRequest|IncomingRequest $request; private ?Response $response = null; protected function setUp(): void @@ -43,6 +43,14 @@ protected function setUp(): void $this->config = new \Config\Filters(); } + protected function tearDown(): void + { + parent::tearDown(); + + $this->resetServices(); + Factories::reset('config'); + } + public function testDoNotCheckCliRequest(): void { $this->config->globals = [ @@ -50,8 +58,8 @@ public function testDoNotCheckCliRequest(): void 'after' => [], ]; - $this->request = Services::clirequest(null, false); - $this->response = service('response'); + $this->request = single_service('clirequest', null); + $this->response = single_service('response'); $filters = new Filters($this->config, $this->request, $this->response); $uri = 'admin/foo/bar'; @@ -68,8 +76,8 @@ public function testPassGetRequest(): void 'after' => [], ]; - $this->request = service('incomingrequest', null, false); - $this->response = service('response'); + $this->request = single_service('incomingrequest', null); + $this->response = single_service('response'); $filters = new Filters($this->config, $this->request, $this->response); $uri = 'admin/foo/bar'; @@ -79,4 +87,99 @@ public function testPassGetRequest(): void // GET request is not protected, so no SecurityException will be thrown. $this->assertSame($this->request, $request); } + + public function testBeforeAddsVaryHeaderForFetchMetadataVerification(): void + { + $filter = new CSRF(); + $request = single_service('incomingrequest', null) + ->withMethod('POST') + ->setHeader('Sec-Fetch-Site', 'same-origin'); + + $filter->before($request); + + $this->assertSame('Sec-Fetch-Site', service('response')->getHeaderLine('Vary')); + } + + public function testBeforeAddsVaryHeaderWhenFetchMetadataFallsBackToToken(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + + $filter = new CSRF(); + $request = single_service('incomingrequest', null) + ->withMethod('POST'); + + $filter->before($request); + + $this->assertSame('Sec-Fetch-Site', service('response')->getHeaderLine('Vary')); + } + + public function testBeforeAppendsVaryHeaderForFetchMetadataVerification(): void + { + $filter = new CSRF(); + $request = single_service('incomingrequest', null) + ->withMethod('POST') + ->setHeader('Sec-Fetch-Site', 'same-origin'); + service('response')->setHeader('Vary', 'Accept-Language'); + + $filter->before($request); + + $this->assertSame('Accept-Language, Sec-Fetch-Site', service('response')->getHeaderLine('Vary')); + } + + public function testBeforeAddsVaryHeaderToRedirectResponseForFetchMetadataVerification(): void + { + $config = new SecurityConfig(); + $config->redirect = true; + Factories::injectMock('config', 'Security', $config); + + $filter = new CSRF(); + $request = single_service('incomingrequest', null) + ->withMethod('POST') + ->setHeader('Sec-Fetch-Site', 'cross-site'); + + $response = $filter->before($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('Sec-Fetch-Site', $response->getHeaderLine('Vary')); + } + + public function testBeforeThrowsExceptionForRejectedFetchMetadataVerification(): void + { + $filter = new CSRF(); + $request = single_service('incomingrequest', null) + ->withMethod('POST') + ->setHeader('Sec-Fetch-Site', 'cross-site'); + + try { + $filter->before($request); + + $this->fail('Expected SecurityException was not thrown.'); + } catch (SecurityException) { + $this->assertSame('Sec-Fetch-Site', service('response')->getHeaderLine('Vary')); + } + } + + public function testBeforeUsesSecurityServiceConfigForVaryHeader(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a') + ->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a'); + + $config = new SecurityConfig(); + $config->csrfFetchMetadata = false; + Services::injectMock('security', new MockSecurity($config)); + + $filter = new CSRF(); + $request = single_service('incomingrequest', null) + ->withMethod('POST') + ->setHeader('Sec-Fetch-Site', 'same-origin'); + + $filter->before($request); + + $this->assertSame('', service('response')->getHeaderLine('Vary')); + } } diff --git a/tests/system/Filters/PageCacheTest.php b/tests/system/Filters/PageCacheTest.php index baf46fc28cac..7a45cc3641be 100644 --- a/tests/system/Filters/PageCacheTest.php +++ b/tests/system/Filters/PageCacheTest.php @@ -48,21 +48,21 @@ public function testDefaultConfigCachesAllStatusCodes(): void $request = $this->createRequest(); - $response200 = new Response(new App()); + $response200 = new Response(); $response200->setStatusCode(200); $response200->setBody('Success'); $result = $filter->after($request, $response200); $this->assertInstanceOf(Response::class, $result); - $response404 = new Response(new App()); + $response404 = new Response(); $response404->setStatusCode(404); $response404->setBody('Not Found'); $result = $filter->after($request, $response404); $this->assertInstanceOf(Response::class, $result); - $response500 = new Response(new App()); + $response500 = new Response(); $response500->setStatusCode(500); $response500->setBody('Server Error'); @@ -79,7 +79,7 @@ public function testRestrictedConfigOnlyCaches200Responses(): void $request = $this->createRequest(); // Test 200 response - should be cached - $response200 = new Response(new App()); + $response200 = new Response(); $response200->setStatusCode(200); $response200->setBody('Success'); @@ -87,7 +87,7 @@ public function testRestrictedConfigOnlyCaches200Responses(): void $this->assertInstanceOf(Response::class, $result); // Test 404 response - should NOT be cached - $response404 = new Response(new App()); + $response404 = new Response(); $response404->setStatusCode(404); $response404->setBody('Not Found'); @@ -95,7 +95,7 @@ public function testRestrictedConfigOnlyCaches200Responses(): void $this->assertNotInstanceOf(ResponseInterface::class, $result); // Test 500 response - should NOT be cached - $response500 = new Response(new App()); + $response500 = new Response(); $response500->setStatusCode(500); $response500->setBody('Server Error'); @@ -111,21 +111,21 @@ public function testCustomCacheStatusCodes(): void $request = $this->createRequest(); - $response200 = new Response(new App()); + $response200 = new Response(); $response200->setStatusCode(200); $response200->setBody('Success'); $result = $filter->after($request, $response200); $this->assertInstanceOf(Response::class, $result); - $response404 = new Response(new App()); + $response404 = new Response(); $response404->setStatusCode(404); $response404->setBody('Not Found'); $result = $filter->after($request, $response404); $this->assertInstanceOf(Response::class, $result); - $response410 = new Response(new App()); + $response410 = new Response(); $response410->setStatusCode(410); $response410->setBody('Gone'); @@ -133,7 +133,7 @@ public function testCustomCacheStatusCodes(): void $this->assertInstanceOf(Response::class, $result); // Test 500 response - should NOT be cached (not in whitelist) - $response500 = new Response(new App()); + $response500 = new Response(); $response500->setStatusCode(500); $response500->setBody('Server Error'); @@ -163,7 +163,7 @@ public function testRedirectResponseNotCached(): void $request = $this->createRequest(); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $response->redirect('/new-url'); $result = $filter->after($request, $response); diff --git a/tests/system/Filters/RequestIdTest.php b/tests/system/Filters/RequestIdTest.php new file mode 100644 index 000000000000..03804d27a88f --- /dev/null +++ b/tests/system/Filters/RequestIdTest.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Filters; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class RequestIdTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + context()->clearAll(); + } + + public function testBefore(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertNotEmpty($requestId); + $this->assertSame($requestId, $requestIdFromHeader); + $this->assertSame(32, strlen($requestId)); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testBeforeWithExistingRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $existingRequestId = 'test-request-id-123'; + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertSame($existingRequestId, $requestId); + $this->assertSame($existingRequestId, $requestIdFromHeader); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testBeforeWithExistingInvalidRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $existingRequestId = 'Abc@!#$'; + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertNotSame($existingRequestId, $requestId); + $this->assertSame($requestId, $requestIdFromHeader); + $this->assertSame(32, strlen($requestId)); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testBeforeWithExistingLongRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + + $existingRequestId = str_repeat('a', 65); + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $requestId = context()->get('request_id'); + + $requestIdFromHeader = $request->getHeaderLine('X-Request-ID'); + + $this->assertNotSame($existingRequestId, $requestId); + $this->assertSame($requestId, $requestIdFromHeader); + $this->assertSame(32, strlen($requestId)); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9._:-]+$/', $requestId); + } + + public function testAfter(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + context()->set('request_id', 'test-request-id-123'); + + $filter->after($request, $response); + + $this->assertTrue($response->hasHeader('X-Request-ID')); + $this->assertSame('test-request-id-123', $response->getHeaderLine('X-Request-ID')); + } + + public function testAfterWithoutRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + context()->remove('request_id'); + + $filter->after($request, $response); + + $this->assertFalse($response->hasHeader('X-Request-ID')); + } + + public function testResponseOutputsRequestIdFromRequestHeader(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + $existingRequestId = 'test-request-id-123'; + $request->setHeader('X-Request-ID', $existingRequestId); + + $filter->before($request); + + $filter->after($request, $response); + + $this->assertTrue($response->hasHeader('X-Request-ID')); + $this->assertSame($existingRequestId, $response->getHeaderLine('X-Request-ID')); + } + + public function testResponseOutputsGeneratedRequestId(): void + { + $filter = new RequestId(); + $request = service('request', null, false); + $response = service('response', null, false); + + context()->remove('request_id'); + + $filter->before($request); + + $generatedRequestId = context()->get('request_id'); + + $filter->after($request, $response); + + $this->assertTrue($response->hasHeader('X-Request-ID')); + $this->assertSame($generatedRequestId, $response->getHeaderLine('X-Request-ID')); + } +} diff --git a/tests/system/Format/JSONFormatterTest.php b/tests/system/Format/JSONFormatterTest.php index 027c618dc4f0..7581a6022a9a 100644 --- a/tests/system/Format/JSONFormatterTest.php +++ b/tests/system/Format/JSONFormatterTest.php @@ -13,6 +13,8 @@ namespace CodeIgniter\Format; +use CodeIgniter\Config\Services; +use CodeIgniter\EnvironmentDetector; use CodeIgniter\Format\Exceptions\FormatException; use CodeIgniter\Test\CIUnitTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -32,6 +34,12 @@ protected function setUp(): void $this->jsonFormatter = new JSONFormatter(); } + protected function tearDown(): void + { + parent::tearDown(); + Services::resetSingle('environment'); + } + /** * @param array $data */ @@ -62,4 +70,11 @@ public function testJSONFormatterThrowsError(): void $this->assertSame('Boom', $this->jsonFormatter->format(["\xB1\x31"])); } + + public function testFormattingToJsonIsCompactInProduction(): void + { + Services::injectMock('environment', new EnvironmentDetector('production')); + + $this->assertSame('{"foo":"bar"}', $this->jsonFormatter->format(['foo' => 'bar'])); + } } diff --git a/tests/system/HTTP/CLIRequestTest.php b/tests/system/HTTP/CLIRequestTest.php index 0bf3da0a6848..5d9d2ca7c75b 100644 --- a/tests/system/HTTP/CLIRequestTest.php +++ b/tests/system/HTTP/CLIRequestTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\App; use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -178,7 +179,6 @@ public function testParsingArgs(): void 'param3', ]); - // reinstantiate it to force parsing $this->request = new CLIRequest(new App()); $options = [ @@ -273,6 +273,64 @@ public function testParsingMalformed3(): void $this->assertSame('users/21/profile/bar', $this->request->getPath()); } + public function testParsingWithArrayOptions(): void + { + service('superglobals')->setServer('argv', [ + 'index.php', + 'users', + '21', + 'profile', + '--foo', + 'oops', + '--foo', + 'bar', + '--baz', + 'queue', + ]); + $this->request = new CLIRequest(new App()); + + $this->assertSame('users/21/profile', $this->request->getPath()); + $this->assertSame('bar', $this->request->getOption('foo')); + $this->assertSame('queue', $this->request->getOption('baz')); + $this->assertSame(['oops', 'bar'], $this->request->getRawOption('foo')); + $this->assertSame('queue', $this->request->getRawOption('baz')); + $this->assertSame('-foo oops -foo bar -baz queue', $this->request->getOptionString()); + $this->assertSame('--foo oops --foo bar --baz queue', $this->request->getOptionString(true)); + } + + /** + * @param list $options + */ + #[DataProvider('provideGetOptionString')] + public function testGetOptionString(array $options, string $optionString): void + { + service('superglobals')->setServer('argv', ['index.php', 'b', 'c', ...$options]); + $this->request = new CLIRequest(new App()); + + $this->assertSame($optionString, $this->request->getOptionString(true)); + } + + /** + * @return iterable, 1: string}> + */ + public static function provideGetOptionString(): iterable + { + yield [ + ['--parm', 'pvalue'], + '--parm pvalue', + ]; + + yield [ + ['--parm', 'p value'], + '--parm "p value"', + ]; + + yield [ + ['--key', 'val1', '--key', 'val2', '--opt', '--bar'], + '--key val1 --key val2 --opt --bar', + ]; + } + public function testFetchGlobalsSingleValue(): void { service('superglobals')->setPost('foo', 'bar'); diff --git a/tests/system/HTTP/CURLRequestRetryTest.php b/tests/system/HTTP/CURLRequestRetryTest.php new file mode 100644 index 000000000000..a8e36e3b7f3a --- /dev/null +++ b/tests/system/HTTP/CURLRequestRetryTest.php @@ -0,0 +1,344 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +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\MockCURLRequest; +use Config\App; +use Config\CURLRequest as ConfigCURLRequest; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class CURLRequestRetryTest extends CIUnitTestCase +{ + private MockCURLRequest $request; + + protected function setUp(): void + { + parent::setUp(); + + $this->resetServices(); + Services::injectMock('superglobals', new Superglobals()); + $this->request = $this->getRequest(); + } + + /** + * @param array $options + */ + private function getRequest(array $options = []): MockCURLRequest + { + $uri = new URI($options['baseURI'] ?? null); + + $config = new ConfigCURLRequest(); + $config->shareOptions = false; + + Factories::injectMock('config', 'CURLRequest', $config); + + return new MockCURLRequest(new App(), $uri, new Response(), $options); + } + + public function testRetryIntegerRetriesDefaultStatusCodes(): void + { + $this->request->setOutputs([ + "HTTP/1.1 503 Service Unavailable\r\n\r\nFirst failure", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => 3, + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Success', $response->getBody()); + $this->assertSame([1.0], $this->request->getSleeps()); + } + + public function testRetryIntegerRetriesDefaultGatewayTimeoutStatusCode(): void + { + $this->request->setOutputs([ + "HTTP/1.1 504 Gateway Timeout\r\n\r\nFirst failure", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => 1, + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Success', $response->getBody()); + $this->assertSame([1.0], $this->request->getSleeps()); + } + + public function testRetryUsesCustomStatusCodes(): void + { + $this->request->setOutputs([ + "HTTP/1.1 500 Internal Server Error\r\n\r\nFirst failure", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + 'status_codes' => [500], + ], + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([0.1], $this->request->getSleeps()); + } + + public function testRetryDoesNotRetryUnconfiguredStatusCode(): void + { + $this->request->setOutputs([ + "HTTP/1.1 404 Not Found\r\n\r\nMissing", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => 3, + 'http_errors' => false, + ]); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame('Missing', $response->getBody()); + $this->assertSame([], $this->request->getSleeps()); + } + + public function testZeroRetriesDisableRetryHandling(): void + { + $this->request->setOutput("HTTP/1.1 200 OK\r\n\r\nSuccess"); + + $this->request->get('http://example.com', [ + 'retry' => ['max_retries' => 0], + ]); + + $this->assertTrue($this->request->curl_options[CURLOPT_FAILONERROR]); + $this->assertSame([], $this->request->getSleeps()); + } + + public function testRetryUsesDelayBackoffArray(): void + { + $this->request->setOutputs([ + "HTTP/1.1 503 Service Unavailable\r\n\r\nFirst failure", + "HTTP/1.1 503 Service Unavailable\r\n\r\nSecond failure", + "HTTP/1.1 503 Service Unavailable\r\n\r\nThird failure", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 3, + 'delay' => [100, 500], + ], + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([0.1, 0.5, 0.5], $this->request->getSleeps()); + } + + public function testRetryClampsNegativeDelays(): void + { + $this->request->setOutputs([ + "HTTP/1.1 503 Service Unavailable\r\n\r\nFirst failure", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => -100, + ], + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([0.0], $this->request->getSleeps()); + } + + public function testRetryAfterSecondsOverridesConfiguredDelay(): void + { + $this->request->setOutputs([ + "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 2\r\n\r\nRate limited", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + ], + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([2.0], $this->request->getSleeps()); + } + + public function testRetryAfterDateOverridesConfiguredDelay(): void + { + $retryAfter = gmdate('D, d M Y H:i:s', time() + 3600) . ' GMT'; + + $this->request->setOutputs([ + "HTTP/1.1 503 Service Unavailable\r\nRetry-After: {$retryAfter}\r\n\r\nUnavailable", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + 'max_delay' => 5000, + ], + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([5.0], $this->request->getSleeps()); + } + + public function testRetryAfterIsCappedByDefaultMaxDelay(): void + { + $this->request->setOutputs([ + "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 3600\r\n\r\nRate limited", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => 1, + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([30.0], $this->request->getSleeps()); + } + + public function testRetryAfterCanBeDisabled(): void + { + $this->request->setOutputs([ + "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 2\r\n\r\nRate limited", + "HTTP/1.1 200 OK\r\n\r\nSuccess", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + 'respect_retry_after' => false, + ], + 'http_errors' => false, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([0.1], $this->request->getSleeps()); + } + + public function testRetryThrowsAfterExhaustingRetriesWhenHttpErrorsEnabled(): void + { + $this->request->setOutputs([ + "HTTP/1.1 503 Service Unavailable\r\n\r\nFirst failure", + "HTTP/1.1 503 Service Unavailable\r\n\r\nFinal failure", + ]); + + $this->expectException(HTTPException::class); + $this->expectExceptionMessage('22 : The requested URL returned error: 503'); + + $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + ], + ]); + } + + public function testRetryReturnsFinalResponseAfterExhaustingRetriesWhenHttpErrorsDisabled(): void + { + $this->request->setOutputs([ + "HTTP/1.1 503 Service Unavailable\r\n\r\nFirst failure", + "HTTP/1.1 503 Service Unavailable\r\n\r\nFinal failure", + ]); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + ], + 'http_errors' => false, + ]); + + $this->assertSame(503, $response->getStatusCode()); + $this->assertSame('Final failure', $response->getBody()); + $this->assertSame([0.1], $this->request->getSleeps()); + } + + public function testCurlErrorsAreNotRetriedByDefault(): void + { + $this->request->setCurlErrors([ + [CURLE_OPERATION_TIMEDOUT, 'Operation timed out'], + ]); + + $this->expectException(HTTPException::class); + + $this->request->get('http://example.com', [ + 'retry' => 3, + ]); + } + + public function testCurlErrorsCanBeRetried(): void + { + $this->request->setCurlErrors([ + [CURLE_OPERATION_TIMEDOUT, 'Operation timed out'], + ])->setOutput("HTTP/1.1 200 OK\r\n\r\nSuccess"); + + $response = $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + 'curl_errors' => true, + ], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Success', $response->getBody()); + $this->assertSame([0.1], $this->request->getSleeps()); + } + + public function testNonTransientCurlErrorsAreNotRetried(): void + { + $this->request->setCurlErrors([ + [CURLE_UNSUPPORTED_PROTOCOL, 'Unsupported protocol'], + ]); + + $this->expectException(HTTPException::class); + + $this->request->get('http://example.com', [ + 'retry' => [ + 'max_retries' => 1, + 'delay' => 100, + 'curl_errors' => true, + ], + ]); + } +} diff --git a/tests/system/HTTP/CURLRequestShareOptionsTest.php b/tests/system/HTTP/CURLRequestShareOptionsTest.php index 6e52a345e32a..9716351f486e 100644 --- a/tests/system/HTTP/CURLRequestShareOptionsTest.php +++ b/tests/system/HTTP/CURLRequestShareOptionsTest.php @@ -34,8 +34,7 @@ final class CURLRequestShareOptionsTest extends CURLRequestTest */ protected function getRequest(array $options = [], ?array $shareConnectionOptions = null): MockCURLRequest { - $uri = isset($options['baseURI']) ? new URI($options['baseURI']) : new URI(); - $app = new App(); + $uri = new URI($options['baseURI'] ?? null); $config = new ConfigCURLRequest(); $config->shareOptions = true; @@ -46,7 +45,7 @@ protected function getRequest(array $options = [], ?array $shareConnectionOption Factories::injectMock('config', 'CURLRequest', $config); - return new MockCURLRequest(($app), $uri, new Response($app), $options); + return new MockCURLRequest(new App(), $uri, new Response(), $options); } public function testHeaderContentLengthNotSharedBetweenRequests(): void diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index 9fac71493b39..aece2bf26354 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -52,8 +52,7 @@ protected function setUp(): void */ protected function getRequest(array $options = [], ?array $shareConnectionOptions = null): MockCURLRequest { - $uri = isset($options['baseURI']) ? new URI($options['baseURI']) : new URI(); - $app = new App(); + $uri = new URI($options['baseURI'] ?? null); $config = new ConfigCURLRequest(); $config->shareOptions = false; @@ -64,7 +63,7 @@ protected function getRequest(array $options = [], ?array $shareConnectionOption Factories::injectMock('config', 'CURLRequest', $config); - return new MockCURLRequest(($app), $uri, new Response($app), $options); + return new MockCURLRequest(new App(), $uri, new Response(), $options); } /** @@ -95,6 +94,17 @@ public function testGetRemembersBaseURI(): void $this->assertSame('http://www.foo.com/api/v1/products', $options[CURLOPT_URL]); } + public function testGetPreservesDottedQueryKeysFromBaseURI(): void + { + $request = $this->getRequest(['baseURI' => 'https://api.example.com/resource?foo.bar=baz']); + + $request->get(''); + + $options = $request->curl_options; + + $this->assertSame('https://api.example.com/resource?foo.bar=baz', $options[CURLOPT_URL]); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/1029 */ diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 5bced590228a..de9670fdf749 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -49,11 +49,10 @@ private function prepare(bool $CSPEnabled = true): void { $this->resetServices(); - $config = config(App::class); + // Needed by CSP + config(App::class)->CSPEnabled = $CSPEnabled; - $config->CSPEnabled = $CSPEnabled; - - $this->response = new Response($config); + $this->response = new Response(); $this->response->pretend(false); $this->csp = $this->response->getCSP(); @@ -181,7 +180,7 @@ public function testConfigSetsListAsDirectivesValues(): void $csp = new ContentSecurityPolicy($config); - $response = new Response(new App()); + $response = new Response(); $response->pretend(true); $response->setBody('Blah blah blah blah'); @@ -732,13 +731,33 @@ public function testBodyScriptNonce(): void $this->assertStringContainsString('nonce-', $header); } + public function testDisabledScriptNonce(): void + { + $this->csp->clearDirective('script-src'); + + $this->csp->setEnableScriptNonce(false); + $this->csp->addScriptSrc('self'); + $this->csp->addScriptSrc('cdn.cloudy.com'); + + $this->assertTrue($this->work('')); + + $header = $this->response->getHeaderLine('Content-Security-Policy'); + $body = $this->response->getBody(); + + $this->assertIsString($body); + $this->assertStringNotContainsString('nonce=', $body); + + $this->assertStringContainsString("script-src 'self' cdn.cloudy.com", $header); + $this->assertStringNotContainsString("script-src 'self' cdn.cloudy.com nonce-", $header); + } + public function testBodyScriptNonceCustomScriptTag(): void { $config = new CSPConfig(); $config->scriptNonceTag = '{custom-script-nonce-tag}'; $csp = new ContentSecurityPolicy($config); - $response = new Response(new App()); + $response = new Response(); $response->pretend(true); $body = 'Blah blah {custom-script-nonce-tag} blah blah'; $response->setBody($body); @@ -754,7 +773,7 @@ public function testBodyScriptNonceDisableAutoNonce(): void $config->autoNonce = false; $csp = new ContentSecurityPolicy($config); - $response = new Response(new App()); + $response = new Response(); $response->pretend(true); $body = 'Blah blah {csp-script-nonce} blah blah'; $response->setBody($body); @@ -773,7 +792,7 @@ public function testBodyStyleNonceDisableAutoNonce(): void $config->autoNonce = false; $csp = new ContentSecurityPolicy($config); - $response = new Response(new App()); + $response = new Response(); $response->pretend(true); $body = 'Blah blah {csp-style-nonce} blah blah'; $response->setBody($body); @@ -811,13 +830,33 @@ public function testBodyStyleNonce(): void $this->assertStringContainsString('nonce-', $header); } + public function testDisabledStyleNonce(): void + { + $this->csp->clearDirective('style-src'); + + $this->csp->setEnableStyleNonce(false); + $this->csp->addStyleSrc('self'); + $this->csp->addStyleSrc('cdn.cloudy.com'); + + $this->assertTrue($this->work('')); + + $header = $this->response->getHeaderLine('Content-Security-Policy'); + $body = $this->response->getBody(); + + $this->assertIsString($body); + $this->assertStringNotContainsString('nonce=', $body); + + $this->assertStringContainsString("style-src 'self' cdn.cloudy.com", $header); + $this->assertStringNotContainsString("style-src 'self' cdn.cloudy.com nonce-", $header); + } + public function testBodyStyleNonceCustomStyleTag(): void { $config = new CSPConfig(); $config->styleNonceTag = '{custom-style-nonce-tag}'; $csp = new ContentSecurityPolicy($config); - $response = new Response(new App()); + $response = new Response(); $response->pretend(true); $body = 'Blah blah {custom-style-nonce-tag} blah blah'; $response->setBody($body); diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php new file mode 100644 index 000000000000..e01c6ebb3509 --- /dev/null +++ b/tests/system/HTTP/FormRequestTest.php @@ -0,0 +1,930 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Config\Services; +use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\Input\ValidatedInput; +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockCodeIgniter; +use Config\App; +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use Tests\Support\Controllers\FormRequestController; +use Tests\Support\HTTP\Requests\ValidPostFormRequest; + +/** + * @internal + */ +#[BackupGlobals(true)] +#[Group('Others')] +final class FormRequestTest extends CIUnitTestCase +{ + private MockCodeIgniter $codeigniter; + + protected function setUp(): void + { + parent::setUp(); + + $this->resetServices(); + Services::injectMock('superglobals', new Superglobals()); + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); + service('superglobals')->setServer('SERVER_NAME', 'example.com'); + service('superglobals')->setServer('HTTP_HOST', 'example.com'); + /** @var Response $response */ + $response = service('response'); + $response->pretend(true); + $this->codeigniter = new MockCodeIgniter(new App()); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->resetServices(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Creates an IncomingRequest. + * POST data must be set on the superglobals BEFORE calling this. + */ + private function makeRequest(?string $body = null): IncomingRequest + { + $config = new App(); + + return new IncomingRequest($config, new SiteURI($config), $body, new UserAgent()); + } + + /** + * Returns a concrete FormRequest subclass instance that requires + * 'title' (required|min_length[3]) and 'body' (required). + */ + private function makeFormRequest(IncomingRequest $request): FormRequest + { + return new class ($request) extends FormRequest { + public function rules(): array + { + return [ + 'title' => 'required|min_length[3]', + 'body' => 'required', + ]; + } + }; + } + + /** + * Injects a router pointing at FormRequestController::$method + * for the given URI, then runs the app and returns the response. + */ + private function runRequest(string $uri, string $method, string $httpMethod = 'GET'): ResponseInterface + { + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', $httpMethod); + $superglobals->setServer('REQUEST_URI', $uri); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); + $superglobals->setServer('argv', ['index.php', ltrim($uri, '/')]); + $superglobals->setServer('argc', 2); + + $routes = service('routes'); + $routes->setAutoRoute(false); + $routes->add(ltrim($uri, '/'), '\\' . FormRequestController::class . '::' . $method); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + return $this->codeigniter->run(null, true); + } + + // ------------------------------------------------------------------------- + // Default behaviours + // ------------------------------------------------------------------------- + + public function testDefaultMessagesReturnsEmptyArray(): void + { + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $this->assertSame([], $formRequest->messages()); + } + + public function testDefaultAuthorizeReturnsTrue(): void + { + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $this->assertTrue($formRequest->isAuthorized()); + } + + public function testConstructorThrowsWhenFallbackRequestIsNotIncomingRequest(): void + { + Services::injectMock('request', new CLIRequest(new App())); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires an IncomingRequest instance, got CodeIgniter\HTTP\CLIRequest.'); + + new class () extends FormRequest { + public function rules(): array + { + return []; + } + }; + } + + public function testGetValidatedReturnsEmptyArrayBeforeResolution(): void + { + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $this->assertSame([], $formRequest->getValidated()); + } + + // ------------------------------------------------------------------------- + // Successful resolution + // ------------------------------------------------------------------------- + + public function testResolveRequestPassesWithValidData(): void + { + service('superglobals')->setPost('title', 'Hello World'); + service('superglobals')->setPost('body', 'Some body text'); + + $formRequest = $this->makeFormRequest($this->makeRequest()); + $this->assertNotInstanceOf(ResponseInterface::class, $formRequest->resolveRequest()); + + $this->assertSame( + ['title' => 'Hello World', 'body' => 'Some body text'], + $formRequest->getValidated(), + ); + } + + public function testGetValidatedReturnsOnlyFieldsCoveredByRules(): void + { + service('superglobals')->setPost('title', 'Hello World'); + service('superglobals')->setPost('body', 'Some body text'); + service('superglobals')->setPost('extra_field', 'should be excluded'); + + $formRequest = $this->makeFormRequest($this->makeRequest()); + $formRequest->resolveRequest(); + + $validated = $formRequest->getValidated(); + + $this->assertArrayHasKey('title', $validated); + $this->assertArrayHasKey('body', $validated); + $this->assertArrayNotHasKey('extra_field', $validated); + } + + public function testGetValidatedReturnsNestedValidatedData(): void + { + service('superglobals')->setPost('post', [ + 'title' => 'Hello World', + 'meta' => [ + 'slug' => 'hello-world', + ], + ]); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return [ + 'post.title' => 'required', + 'post.meta.slug' => 'required', + ]; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame( + [ + 'post' => [ + 'title' => 'Hello World', + 'meta' => [ + 'slug' => 'hello-world', + ], + ], + ], + $formRequest->getValidated(), + ); + } + + public function testGetValidatedReturnsNullValidatedField(): void + { + service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); + + $formRequest = new class ($this->makeRequest('{"note":null}')) extends FormRequest { + public function rules(): array + { + return ['note' => 'permit_empty']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(['note' => null], $formRequest->getValidated()); + } + + public function testGetValidatedInputReturnsValidatedInputObject(): void + { + service('superglobals')->setPost('title', 'Hello World'); + service('superglobals')->setPost('body', 'Some body text'); + + $formRequest = new ValidPostFormRequest($this->makeRequest()); + $formRequest->resolveRequest(); + + $input = $formRequest->getValidatedInput(); + + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame('Hello World', $input->get('title')); + } + + // ------------------------------------------------------------------------- + // prepareForValidation hook + // ------------------------------------------------------------------------- + + public function testPrepareForValidationIsCalledBeforeValidation(): void + { + service('superglobals')->setPost('title', 'Hi'); // Too short - min_length[3] would normally fail. + + // This FormRequest normalises the title in prepareForValidation so it passes. + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public static bool $prepareCalled = false; + + public function rules(): array + { + return ['title' => 'required|min_length[3]']; + } + + protected function prepareForValidation(array $data): array + { + self::$prepareCalled = true; + // Extend the title so it meets the min_length rule. + $data['title'] = 'Hi extended'; + + return $data; + } + }; + + $this->assertNotInstanceOf(ResponseInterface::class, $formRequest->resolveRequest()); + + $this->assertTrue($formRequest::$prepareCalled); + $this->assertSame('Hi extended', $formRequest->getValidated()['title']); + } + + // ------------------------------------------------------------------------- + // Validation failure + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testFailedValidationFlashesErrorsToSession(): void + { + /** @var array $_SESSION */ + $_SESSION = []; + + // No POST data - both required rules will fail and the default + // failedValidation() should flash errors to _ci_validation_errors. + $formRequest = $this->makeFormRequest($this->makeRequest()); + $formRequest->resolveRequest(); + + $this->assertArrayHasKey('_ci_validation_errors', $_SESSION); + $this->assertArrayHasKey('title', $_SESSION['_ci_validation_errors']); + $this->assertArrayHasKey('body', $_SESSION['_ci_validation_errors']); + } + + public function testResolveRequestReturnsResponseOnInvalidData(): void + { + // No POST data - both required rules will fail. + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + } + + public function testFailedValidationResponseContainsErrors(): void + { + service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); + + $formRequest = new class ($this->makeRequest('{}')) extends FormRequest { + public function rules(): array + { + return ['title' => 'required']; + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(422, $response->getStatusCode()); + + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('title', $body['errors']); + } + + public function testResolveRequestReturns422ForJsonRequest(): void + { + service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); + // Body is an empty JSON object - no fields provided. + $formRequest = $this->makeFormRequest($this->makeRequest('{}')); + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(422, $response->getStatusCode()); + } + + public function testResolveRequestReturns422WhenJsonIsPreferred(): void + { + service('superglobals')->setServer('HTTP_ACCEPT', 'application/json'); + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(422, $response->getStatusCode()); + + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('errors', $body); + $this->assertArrayHasKey('title', $body['errors']); + } + + public function testResolveRequestRedirectsForAjaxRequest(): void + { + service('superglobals')->setServer('HTTP_X_REQUESTED_WITH', 'XMLHttpRequest'); + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(303, $response->getStatusCode()); + } + + public function testResolveRequestReturns422ForAjaxRequestThatPrefersJson(): void + { + service('superglobals')->setServer('HTTP_ACCEPT', 'application/json'); + service('superglobals')->setServer('HTTP_X_REQUESTED_WITH', 'XMLHttpRequest'); + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(422, $response->getStatusCode()); + } + + public function testResolveRequestRedirectsForWildcardAcceptHeader(): void + { + service('superglobals')->setServer('HTTP_ACCEPT', '*/*'); + $formRequest = $this->makeFormRequest($this->makeRequest()); + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(303, $response->getStatusCode()); + } + + public function testPreparedValidationDataIsPassedToFailedValidationWithoutPreparingAgain(): void + { + service('superglobals')->setPost('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public int $prepareCount = 0; + + /** + * @var array + */ + public array $preparedData = []; + + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $this->prepareCount++; + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + + protected function failedValidation(array $errors, array $preparedData): ResponseInterface + { + $this->preparedData = $preparedData; + + return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(1, $formRequest->prepareCount); + $this->assertSame(['title' => 'Hello World'], $formRequest->preparedData); + } + + #[RunInSeparateProcess] + public function testDefaultFailedValidationFlashesPreparedValidationDataAsOldInput(): void + { + /** @var array $_SESSION */ + $_SESSION = []; + + service('superglobals')->setPost('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(['title' => 'Hello World'], $_SESSION['_ci_old_input']['post']); + } + + #[RunInSeparateProcess] + public function testDefaultFailedValidationFlashesPreparedGetDataAsOldInput(): void + { + /** @var array $_SESSION */ + $_SESSION = []; + + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); + service('superglobals')->setGet('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(['title' => 'Hello World'], $_SESSION['_ci_old_input']['get']); + $this->assertSame([], $_SESSION['_ci_old_input']['post']); + } + + #[RunInSeparateProcess] + public function testDefaultFailedValidationPreservesGetDataWhenPostDataIsPrepared(): void + { + /** @var array $_SESSION */ + $_SESSION = []; + + service('superglobals')->setGet('category', '2'); + service('superglobals')->setPost('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(['category' => '2'], $_SESSION['_ci_old_input']['get']); + $this->assertSame(['title' => 'Hello World'], $_SESSION['_ci_old_input']['post']); + } + + // ------------------------------------------------------------------------- + // Authorization failure + // ------------------------------------------------------------------------- + + public function testResolveRequestReturns403WhenUnauthorized(): void + { + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return []; + } + + public function isAuthorized(): bool + { + return false; + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(403, $response->getStatusCode()); + } + + public function testAuthorizationIsCheckedBeforeValidation(): void + { + // Use a static property to record call order without needing a custom constructor. + $formRequest = new class ($this->makeRequest()) extends FormRequest { + /** + * @var list + */ + public static array $order = []; + + public function rules(): array + { + return ['title' => 'required']; + } + + public function isAuthorized(): bool + { + self::$order[] = 'authorize'; + + return false; + } + + protected function prepareForValidation(array $data): array + { + self::$order[] = 'prepare'; + + return $data; + } + }; + + $formRequest->resolveRequest(); + + // isAuthorized() must fire before prepareForValidation(); validation never runs. + $this->assertSame(['authorize'], $formRequest::$order); + } + + // ------------------------------------------------------------------------- + // Custom overrides + // ------------------------------------------------------------------------- + + public function testValidationDataOverrideIsUsed(): void + { + // No POST data at all - but validationData() supplies its own. + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['title' => 'required|min_length[3]']; + } + + protected function validationData(): array + { + return ['title' => 'Injected Title']; + } + }; + + $this->assertNotInstanceOf(ResponseInterface::class, $formRequest->resolveRequest()); + + $this->assertSame('Injected Title', $formRequest->getValidated()['title']); + } + + public function testCustomFailedValidationIsRespected(): void + { + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public static bool $called = false; + + public function rules(): array + { + return ['title' => 'required']; + } + + protected function failedValidation(array $errors, array $preparedData): ResponseInterface + { + self::$called = true; + + return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertTrue($formRequest::$called); + } + + public function testCustomFailedAuthorizationIsRespected(): void + { + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public static bool $called = false; + + public function rules(): array + { + return []; + } + + public function isAuthorized(): bool + { + return false; + } + + protected function failedAuthorization(): ResponseInterface + { + self::$called = true; + + return service('response')->setStatusCode(403); + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertTrue($formRequest::$called); + } + + // ------------------------------------------------------------------------- + // Integration: BC - methods without FormRequest are unaffected + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testControllerMethodWithoutFormRequestReceivesRouteParam(): void + { + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'GET'); + $superglobals->setServer('REQUEST_URI', '/items/42'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); + $superglobals->setServer('argv', ['index.php', 'items/42']); + $superglobals->setServer('argc', 2); + + $routes = service('routes'); + $routes->setAutoRoute(false); + $routes->add('items/(:segment)', '\\' . FormRequestController::class . '::show/$1'); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + $response = $this->codeigniter->run(null, true); + $this->assertInstanceOf(ResponseInterface::class, $response); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('item-42', (string) $response->getBody()); + } + + // ------------------------------------------------------------------------- + // Integration: valid FormRequest + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testValidFormRequestInjectedAndControllerReceivesValidatedData(): void + { + service('superglobals')->setPost('title', 'My Post'); + service('superglobals')->setPost('body', 'Post content here'); + + $response = $this->runRequest('/posts', 'store', 'POST'); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('My Post', $body['title']); + $this->assertSame('Post content here', $body['body']); + } + + #[RunInSeparateProcess] + public function testRouteParamAndFormRequestBothReachController(): void + { + service('superglobals')->setPost('title', 'Updated Title'); + service('superglobals')->setPost('body', 'Updated body content'); + + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'POST'); + $superglobals->setServer('REQUEST_URI', '/posts/99'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); + $superglobals->setServer('argv', ['index.php', 'posts/99']); + $superglobals->setServer('argc', 2); + + $routes = service('routes'); + $routes->setAutoRoute(false); + $routes->add('posts/(:segment)', '\\' . FormRequestController::class . '::update/$1'); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + $response = $this->codeigniter->run(null, true); + $this->assertInstanceOf(ResponseInterface::class, $response); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('99', $body['id']); + $this->assertSame('Updated Title', $body['data']['title']); + $this->assertSame('Updated body content', $body['data']['body']); + } + + #[RunInSeparateProcess] + public function testOptionalTrailingParamReceivesDefaultWhenSegmentAbsent(): void + { + service('superglobals')->setPost('title', 'Hello'); + service('superglobals')->setPost('body', 'Body text'); + + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'POST'); + $superglobals->setServer('REQUEST_URI', '/posts/42'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); + $superglobals->setServer('argv', ['index.php', 'posts/42']); + $superglobals->setServer('argc', 2); + + $routes = service('routes'); + $routes->setAutoRoute(false); + // Route provides only $id; $format is absent so its default 'json' applies. + $routes->add('posts/(:segment)', '\\' . FormRequestController::class . '::index/$1'); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + $response = $this->codeigniter->run(null, true); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('42', $body['id']); + $this->assertSame('json', $body['format']); + $this->assertSame('Hello', $body['data']['title']); + } + + #[RunInSeparateProcess] + public function testVariadicRouteParamsAlongsideFormRequestAreAllCollected(): void + { + service('superglobals')->setPost('title', 'Tagged Post'); + service('superglobals')->setPost('body', 'Post body'); + + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'POST'); + $superglobals->setServer('REQUEST_URI', '/search/php/ci4'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); + $superglobals->setServer('argv', ['index.php', 'search/php/ci4']); + $superglobals->setServer('argc', 2); + + $routes = service('routes'); + $routes->setAutoRoute(false); + $routes->add('search/(:segment)/(:segment)', '\\' . FormRequestController::class . '::search/$1/$2'); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + $response = $this->codeigniter->run(null, true); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertSame(['php', 'ci4'], $body['tags']); + $this->assertSame('Tagged Post', $body['data']['title']); + } + + #[RunInSeparateProcess] + public function testClosureRouteWithFormRequestIsInjected(): void + { + service('superglobals')->setPost('title', 'Closure Title'); + service('superglobals')->setPost('body', 'Closure body text'); + + $superglobals = service('superglobals'); + $superglobals->setServer('REQUEST_METHOD', 'POST'); + $superglobals->setServer('REQUEST_URI', '/closure/55'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); + $superglobals->setServer('argv', ['index.php', 'closure/55']); + $superglobals->setServer('argc', 2); + + $routes = service('routes'); + $routes->setAutoRoute(false); + $routes->add('closure/(:segment)', static fn (string $id, ValidPostFormRequest $request): string => json_encode(['id' => $id, 'data' => $request->getValidated()])); + + $router = service('router', $routes, service('incomingrequest')); + Services::injectMock('router', $router); + + $response = $this->codeigniter->run(null, true); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('55', $body['id']); + $this->assertSame('Closure Title', $body['data']['title']); + $this->assertSame('Closure body text', $body['data']['body']); + } + + // ------------------------------------------------------------------------- + // Integration: validation failure - web redirect + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testInvalidFormRequestRedirectsWebRequest(): void + { + service('superglobals')->setServer('HTTP_REFERER', 'http://example.com/posts/create'); + + // No POST data → required rules fail. + $response = $this->runRequest('/posts', 'store', 'POST'); + + // For POST requests under HTTP/1.1, CI4 correctly issues 303 See Other (Post/Redirect/Get). + $this->assertSame(303, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Integration: validation failure - JSON + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testInvalidFormRequestReturns422ForJsonRequest(): void + { + service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); + + // No valid POST/JSON data → required rules fail. + $response = $this->runRequest('/posts', 'store', 'POST'); + + $this->assertSame(422, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('errors', $body); + $this->assertArrayHasKey('title', $body['errors']); + } + + #[RunInSeparateProcess] + public function testInvalidFormRequestReturns422WhenJsonIsPreferred(): void + { + service('superglobals')->setServer('HTTP_ACCEPT', 'application/json'); + + $response = $this->runRequest('/posts', 'store', 'POST'); + + $this->assertSame(422, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('errors', $body); + $this->assertArrayHasKey('title', $body['errors']); + } + + #[RunInSeparateProcess] + public function testInvalidFormRequestRedirectsForAjaxRequest(): void + { + service('superglobals')->setServer('HTTP_REFERER', 'http://example.com/posts/create'); + service('superglobals')->setServer('HTTP_X_REQUESTED_WITH', 'XMLHttpRequest'); + + $response = $this->runRequest('/posts', 'store', 'POST'); + + $this->assertSame(303, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Integration: authorization failure + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testUnauthorizedFormRequestReturns403(): void + { + $response = $this->runRequest('/admin/resource', 'restricted', 'POST'); + + $this->assertSame(403, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Integration: getValidated() only returns fields declared in rules() + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testValidatedExcludesFieldsNotInRules(): void + { + service('superglobals')->setPost('title', 'A title that is long enough'); + service('superglobals')->setPost('body', 'The body'); + service('superglobals')->setPost('__csrf_token', 'secret'); + service('superglobals')->setPost('extra_noise', 'ignored'); + + $response = $this->runRequest('/posts', 'store', 'POST'); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayNotHasKey('__csrf_token', $body); + $this->assertArrayNotHasKey('extra_noise', $body); + } +} diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 5a7d5f29dfe2..c16c706a9b8d 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -180,16 +180,6 @@ public function testCanGrabServerVars(): void $this->assertNull($this->request->getServer('TESTY')); } - public function testCanGrabEnvVars(): void - { - $server = $this->getPrivateProperty($this->request, 'globals'); - $server['env']['TEST'] = 5; - $this->setPrivateProperty($this->request, 'globals', $server); - - $this->assertSame('5', $this->request->getEnv('TEST')); - $this->assertNull($this->request->getEnv('TESTY')); - } - public function testCanGrabCookieVars(): void { service('superglobals')->setCookie('TEST', '5'); diff --git a/tests/system/HTTP/RedirectResponseTest.php b/tests/system/HTTP/RedirectResponseTest.php index 2ea700c03d88..6483f2562493 100644 --- a/tests/system/HTTP/RedirectResponseTest.php +++ b/tests/system/HTTP/RedirectResponseTest.php @@ -70,7 +70,7 @@ protected function setUp(): void public function testRedirectToFullURI(): void { - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $response = $response->to('http://example.com/foo'); @@ -80,7 +80,7 @@ public function testRedirectToFullURI(): void public function testRedirectRoute(): void { - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $this->routes->add('exampleRoute', 'Home::index'); @@ -102,7 +102,7 @@ public function testRedirectRouteBadNamedRoute(): void $this->expectException(HTTPException::class); $this->expectExceptionMessage('The route for "differentRoute" cannot be found.'); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $this->routes->add('exampleRoute', 'Home::index'); @@ -114,7 +114,7 @@ public function testRedirectRouteBadControllerMethod(): void $this->expectException(HTTPException::class); $this->expectExceptionMessage('The route for "Bad::badMethod" cannot be found.'); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $this->routes->add('exampleRoute', 'Home::index'); @@ -123,8 +123,7 @@ public function testRedirectRouteBadControllerMethod(): void public function testRedirectRelativeConvertsToFullURI(): void { - $response = new RedirectResponse($this->config); - + $response = new RedirectResponse(); $response = $response->to('/foo'); $this->assertTrue($response->hasHeader('Location')); @@ -139,7 +138,7 @@ public function testWithInput(): void service('superglobals')->setGet('foo', 'bar'); service('superglobals')->setPost('bar', 'baz'); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $returned = $response->withInput(); @@ -155,7 +154,7 @@ public function testWithValidationErrors(): void { $_SESSION = []; - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $validation = $this->createMock(Validation::class); $validation->method('getErrors')->willReturn(['foo' => 'bar']); @@ -173,7 +172,7 @@ public function testWith(): void { $_SESSION = []; - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $returned = $response->with('foo', 'bar'); @@ -190,7 +189,7 @@ public function testRedirectBack(): void $this->request = new MockIncomingRequest($this->config, new SiteURI($this->config), null, new UserAgent()); Services::injectMock('request', $this->request); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $returned = $response->back(); $this->assertSame('http://somewhere.com', $returned->header('location')->getValue()); @@ -202,7 +201,7 @@ public function testRedirectBackMissing(): void { $_SESSION = []; - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $returned = $response->back(); @@ -223,7 +222,7 @@ public function testRedirectRouteBaseUrl(): void $request = new MockIncomingRequest($config, new SiteURI($config), null, new UserAgent()); Services::injectMock('request', $request); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $this->routes->add('exampleRoute', 'Home::index'); @@ -242,7 +241,7 @@ public function testWithCookies(): void $baseResponse = service('response'); $baseResponse->setCookie('foo', 'bar'); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $this->assertFalse($response->hasCookie('foo', 'bar')); $response = $response->withCookies(); @@ -255,7 +254,7 @@ public function testWithCookiesWithEmptyCookies(): void { $_SESSION = []; - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $response = $response->withCookies(); $this->assertEmpty($response->getCookies()); @@ -268,7 +267,7 @@ public function testWithHeaders(): void $baseResponse = service('response'); $baseResponse->setHeader('foo', 'bar'); - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $this->assertFalse($response->hasHeader('foo')); $response = $response->withHeaders(); @@ -289,7 +288,7 @@ public function testWithHeadersWithEmptyHeaders(): void $baseResponse->removeHeader($key); } - $response = new RedirectResponse(new App()); + $response = new RedirectResponse(); $response->withHeaders(); $this->assertEmpty($baseResponse->headers()); diff --git a/tests/system/HTTP/RequestInputTest.php b/tests/system/HTTP/RequestInputTest.php new file mode 100644 index 000000000000..44d094b0ada1 --- /dev/null +++ b/tests/system/HTTP/RequestInputTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Input\InputData; +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[BackupGlobals(true)] +#[Group('SeparateProcess')] +final class RequestInputTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Services::injectMock('superglobals', new Superglobals([], [], [], [], [], [])); + } + + private function createRequest(?App $config = null, ?string $body = null): IncomingRequest + { + $config ??= new App(); + + return new IncomingRequest( + $config, + new SiteURI($config, ''), + $body, + new UserAgent(), + ); + } + + public function testInputReturnsRequestInput(): void + { + $request = $this->createRequest(); + $input = $request->input(); + + $this->assertInstanceOf(RequestInput::class, $input); + } + + public function testInputReturnsSameRequestInputInstance(): void + { + $request = $this->createRequest(); + + $this->assertSame($request->input(), $request->input()); + } + + public function testClonedRequestGetsNewRequestInputInstance(): void + { + $request = $this->createRequest(); + $input = $request->input(); + + $clonedRequest = $request->withMethod(Method::POST); + + $this->assertNotSame($input, $clonedRequest->input()); + } + + public function testGetReadsGetData(): void + { + service('superglobals')->setGet('page', '3'); + service('superglobals')->setGet('filters', ['active' => 'true']); + service('superglobals')->setPost('page', '10'); + + $input = $this->createRequest()->input()->get(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(3, $input->integer('page')); + $this->assertTrue($input->boolean('filters.active')); + $this->assertSame(1, $input->integer('missing', 1)); + } + + public function testPostReadsPostData(): void + { + service('superglobals')->setGet('remember', '0'); + service('superglobals')->setPost('remember', '1'); + service('superglobals')->setPost('tags', ['php', 'ci4']); + + $input = $this->createRequest()->input()->post(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertTrue($input->boolean('remember')); + $this->assertSame(['php', 'ci4'], $input->array('tags')); + } + + public function testJsonReadsJsonBody(): void + { + $json = json_encode([ + 'page' => '4', + 'filters' => ['active' => 'true'], + 'nullable' => null, + ]); + + $input = $this->createRequest(new App(), $json)->input()->json(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(4, $input->integer('page')); + $this->assertTrue($input->boolean('filters.active')); + $this->assertTrue($input->has('nullable')); + } + + public function testJsonReturnsEmptyInputForEmptyJsonBody(): void + { + $input = $this->createRequest(new App())->input()->json(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertFalse($input->has('name')); + } + + public function testJsonRejectsScalarJsonBody(): void + { + $this->expectException(HTTPException::class); + $this->expectExceptionMessage('The provided JSON format is not supported.'); + + $this->createRequest(new App(), '"hello"')->input()->json(); + } + + public function testJsonKeepsInvalidJsonError(): void + { + $this->expectException(HTTPException::class); + $this->expectExceptionMessage('Failed to parse JSON string. Error: Syntax error'); + + $this->createRequest(new App(), 'Invalid JSON string')->input()->json(); + } + + public function testRawReadsRawInputData(): void + { + $input = $this->createRequest(new App(), 'title=Hello&published=1')->input()->raw(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame('Hello', $input->string('title')); + $this->assertTrue($input->boolean('published')); + } +} diff --git a/tests/system/HTTP/ResponseCookieTest.php b/tests/system/HTTP/ResponseCookieTest.php index 6fb03d516a9f..700af9044bbc 100644 --- a/tests/system/HTTP/ResponseCookieTest.php +++ b/tests/system/HTTP/ResponseCookieTest.php @@ -18,7 +18,6 @@ use CodeIgniter\Cookie\CookieStore; use CodeIgniter\Cookie\Exceptions\CookieException; use CodeIgniter\Test\CIUnitTestCase; -use Config\App; use Config\Cookie as CookieConfig; use PHPUnit\Framework\Attributes\Group; @@ -45,7 +44,7 @@ public function testCookiePrefixed(): void { $config = config('Cookie'); $config->prefix = 'mine'; - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $this->assertInstanceOf(Cookie::class, $response->getCookie('foo')); @@ -58,7 +57,7 @@ public function testCookiePrefixed(): void public function testCookiesAll(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $response->setCookie('bee', 'bop'); @@ -70,7 +69,7 @@ public function testCookiesAll(): void public function testSetCookieObject(): void { $cookie = new Cookie('foo', 'bar'); - $response = new Response(new App()); + $response = new Response(); $response->setCookie($cookie); @@ -80,7 +79,7 @@ public function testSetCookieObject(): void public function testCookieGet(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $response->setCookie('bee', 'bop'); @@ -90,7 +89,7 @@ public function testCookieGet(): void public function testCookieDomain(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $cookie = $response->getCookie('foo'); @@ -102,7 +101,7 @@ public function testCookieDomain(): void $config = config('Cookie'); $config->domain = 'mine.com'; - $response = new Response(new App()); + $response = new Response(); $response->setCookie('alu', 'la'); $cookie = $response->getCookie('alu'); $this->assertSame('mine.com', $cookie->getDomain()); @@ -110,7 +109,7 @@ public function testCookieDomain(): void public function testCookiePath(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $cookie = $response->getCookie('foo'); @@ -123,7 +122,7 @@ public function testCookiePath(): void public function testCookieSecure(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $cookie = $response->getCookie('foo'); @@ -136,7 +135,7 @@ public function testCookieSecure(): void public function testCookieHTTPOnly(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $cookie = $response->getCookie('foo'); @@ -149,18 +148,18 @@ public function testCookieHTTPOnly(): void public function testCookieExpiry(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $cookie = $response->getCookie('foo'); $this->assertTrue($cookie->isExpired()); - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bee', 'value' => 'bop', 'expire' => 1000]); $cookie = $response->getCookie('bee'); $this->assertFalse($cookie->isExpired()); - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bee', 'value' => 'bop', 'expire' => -1000]); $cookie = $response->getCookie('bee'); $this->assertSame(0, $cookie->getExpiresTimestamp()); @@ -168,7 +167,7 @@ public function testCookieExpiry(): void public function testCookieDelete(): void { - $response = new Response(new App()); + $response = new Response(); // foo is already expired, bee will stick around $response->setCookie('foo', 'bar'); @@ -177,20 +176,20 @@ public function testCookieDelete(): void $this->assertFalse($cookie->isExpired()); // delete cookie manually - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bee', 'value' => 'bop', 'expire' => '']); $cookie = $response->getCookie('bee'); $this->assertTrue($cookie->isExpired(), $cookie->getExpiresTimestamp() . ' should be less than ' . time()); // delete with no effect - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bee', 'value' => 'bop', 'expire' => 1000]); $response->deleteCookie(); $cookie = $response->getCookie('bee'); $this->assertFalse($cookie->isExpired()); // delete cookie for real - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bee', 'value' => 'bop', 'expire' => 1000]); $response->deleteCookie('bee'); $cookie = $response->getCookie('bee'); @@ -199,7 +198,7 @@ public function testCookieDelete(): void $config = config('Cookie'); // delete cookie for real, with prefix $config->prefix = 'mine'; - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bee', 'value' => 'bop', 'expire' => 1000]); $response->deleteCookie('bee'); $cookie = $response->getCookie('bee'); @@ -207,7 +206,7 @@ public function testCookieDelete(): void // delete cookie with wrong prefix? $config->prefix = 'mine'; - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bee', 'value' => 'bop', 'expire' => 1000]); $response->deleteCookie('bee', '', '', 'wrong'); $cookie = $response->getCookie('bee'); @@ -218,7 +217,7 @@ public function testCookieDelete(): void // delete cookie with wrong domain? $config->domain = '.mine.com'; - $response = new Response(new App()); + $response = new Response(); $response->setCookie(['name' => 'bees', 'value' => 'bop', 'expire' => 1000]); $response->deleteCookie('bees', 'wrong', '', ''); $cookie = $response->getCookie('bees'); @@ -230,7 +229,7 @@ public function testCookieDelete(): void public function testCookieDefaultSetSameSite(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie([ 'name' => 'bar', 'value' => 'foo', @@ -246,7 +245,7 @@ public function testCookieStrictSetSameSite(): void { $config = config('Cookie'); $config->samesite = 'Strict'; - $response = new Response(new App()); + $response = new Response(); $response->setCookie([ 'name' => 'bar', 'value' => 'foo', @@ -263,7 +262,7 @@ public function testCookieBlankSetSameSite(): void /** @var CookieConfig $config */ $config = config('Cookie'); $config->samesite = ''; - $response = new Response(new App()); + $response = new Response(); $response->setCookie([ 'name' => 'bar', 'value' => 'foo', @@ -279,7 +278,7 @@ public function testCookieWithoutSameSite(): void { $config = new CookieConfig(); unset($config->samesite); - $response = new Response(new App()); + $response = new Response(); $response->setCookie([ 'name' => 'bar', 'value' => 'foo', @@ -293,7 +292,7 @@ public function testCookieWithoutSameSite(): void public function testCookieStrictSameSite(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie([ 'name' => 'bar', 'value' => 'foo', @@ -308,7 +307,7 @@ public function testCookieStrictSameSite(): void public function testCookieInvalidSameSite(): void { - $response = new Response(new App()); + $response = new Response(); $this->expectException(CookieException::class); $this->expectExceptionMessage(lang('Cookie.invalidSameSite', ['Invalid'])); @@ -334,7 +333,7 @@ public function testSetCookieConfigCookieIsUsed(): void 'value' => 'foo', 'expire' => 9999, ]; - $response = new Response(new App()); + $response = new Response(); $response->setCookie($cookieAttr); $cookie = $response->getCookie('bar'); @@ -346,7 +345,7 @@ public function testSetCookieConfigCookieIsUsed(): void public function testGetCookieStore(): void { - $response = new Response(new App()); + $response = new Response(); $this->assertInstanceOf(CookieStore::class, $response->getCookieStore()); } } diff --git a/tests/system/HTTP/ResponseSendTest.php b/tests/system/HTTP/ResponseSendTest.php index 211d806ba487..befa6bf785be 100644 --- a/tests/system/HTTP/ResponseSendTest.php +++ b/tests/system/HTTP/ResponseSendTest.php @@ -51,7 +51,7 @@ final class ResponseSendTest extends CIUnitTestCase #[WithoutErrorHandler] public function testHeadersMissingDate(): void { - $response = new Response(new App()); + $response = new Response(); $response->pretend(false); $body = 'Hello'; @@ -87,9 +87,9 @@ public function testHeadersWithCSP(): void $this->resetFactories(); $this->resetServices(); - $config = config('App'); - $config->CSPEnabled = true; - $response = new Response($config); + config(App::class)->CSPEnabled = true; + + $response = new Response(); $response->pretend(false); $body = 'Hello'; @@ -122,7 +122,7 @@ public function testRedirectResponseCookies(): void { $loginTime = time(); - $response = new Response(new App()); + $response = new Response(); $response->pretend(false); $routes = service('routes'); @@ -161,7 +161,7 @@ public function testDoNotSendUnSecureCookie(): void $request->method('isSecure')->willReturn(false); Services::injectMock('request', $request); - $response = new Response(new App()); + $response = new Response(); $response->pretend(false); $body = 'Hello'; $response->setBody($body); @@ -189,7 +189,7 @@ public function testDoNotSendUnSecureCookie(): void #[WithoutErrorHandler] public function testHeaderOverride(): void { - $response = new Response(new App()); + $response = new Response(); $response->pretend(false); $body = 'Hello'; diff --git a/tests/system/HTTP/ResponseTest.php b/tests/system/HTTP/ResponseTest.php index 17d1b4191134..fc175654e9b4 100644 --- a/tests/system/HTTP/ResponseTest.php +++ b/tests/system/HTTP/ResponseTest.php @@ -15,6 +15,8 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; +use CodeIgniter\Cookie\Cookie; +use CodeIgniter\Cookie\CookieStore; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; @@ -24,6 +26,7 @@ use DateTimeZone; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; /** * @internal @@ -52,8 +55,7 @@ protected function tearDown(): void public function testCanSetStatusCode(): void { - $response = new Response(new App()); - + $response = new Response(); $response->setStatusCode(200); $this->assertSame(200, $response->getStatusCode()); @@ -61,25 +63,15 @@ public function testCanSetStatusCode(): void public function testSetStatusCodeThrowsExceptionForBadCodes(): void { - $response = new Response(new App()); + $response = new Response(); $this->expectException(HTTPException::class); $response->setStatusCode(54322); } - public function testConstructWithCSPEnabled(): void - { - $config = new App(); - $config->CSPEnabled = true; - $response = new Response($config); - - $this->assertInstanceOf(Response::class, $response); - } - public function testSetStatusCodeSetsReason(): void { - $response = new Response(new App()); - + $response = new Response(); $response->setStatusCode(200); $this->assertSame('OK', $response->getReasonPhrase()); @@ -87,8 +79,7 @@ public function testSetStatusCodeSetsReason(): void public function testCanSetCustomReasonCode(): void { - $response = new Response(new App()); - + $response = new Response(); $response->setStatusCode(200, 'Not the mama'); $this->assertSame('Not the mama', $response->getReasonPhrase()); @@ -96,7 +87,7 @@ public function testCanSetCustomReasonCode(): void public function testRequiresMessageWithUnknownStatusCode(): void { - $response = new Response(new App()); + $response = new Response(); $this->expectException(HTTPException::class); $this->expectExceptionMessage(lang('HTTP.unknownStatusCode', [115])); @@ -105,7 +96,7 @@ public function testRequiresMessageWithUnknownStatusCode(): void public function testRequiresMessageWithSmallStatusCode(): void { - $response = new Response(new App()); + $response = new Response(); $this->expectException(HTTPException::class); $this->expectExceptionMessage(lang('HTTP.invalidStatusCode', [95])); @@ -114,7 +105,7 @@ public function testRequiresMessageWithSmallStatusCode(): void public function testRequiresMessageWithLargeStatusCode(): void { - $response = new Response(new App()); + $response = new Response(); $this->expectException(HTTPException::class); $this->expectExceptionMessage(lang('HTTP.invalidStatusCode', [695])); @@ -123,8 +114,7 @@ public function testRequiresMessageWithLargeStatusCode(): void public function testSetStatusCodeInterpretsReason(): void { - $response = new Response(new App()); - + $response = new Response(); $response->setStatusCode(300); $this->assertSame('Multiple Choices', $response->getReasonPhrase()); @@ -132,8 +122,7 @@ public function testSetStatusCodeInterpretsReason(): void public function testSetStatusCodeSavesCustomReason(): void { - $response = new Response(new App()); - + $response = new Response(); $response->setStatusCode(300, 'My Little Pony'); $this->assertSame('My Little Pony', $response->getReasonPhrase()); @@ -141,14 +130,14 @@ public function testSetStatusCodeSavesCustomReason(): void public function testGetReasonDefaultsToOK(): void { - $response = new Response(new App()); + $response = new Response(); $this->assertSame('OK', $response->getReasonPhrase()); } public function testSetDateRemembersDateInUTC(): void { - $response = new Response(new App()); + $response = new Response(); $datetime = DateTime::createFromFormat('!Y-m-d', '2000-03-10'); $response->setDate($datetime); @@ -170,7 +159,7 @@ public function testSetLink(): void $this->resetServices(); - $response = new Response($config); + $response = new Response(); $pager = service('pager'); $pager->store('default', 3, 10, 200); @@ -200,8 +189,7 @@ public function testSetLink(): void public function testSetContentType(): void { - $response = new Response(new App()); - + $response = new Response(); $response->setContentType('text/json'); $this->assertSame('text/json; charset=UTF-8', $response->getHeaderLine('Content-Type')); @@ -209,8 +197,7 @@ public function testSetContentType(): void public function testNoCache(): void { - $response = new Response(new App()); - + $response = new Response(); $response->noCache(); $this->assertSame('no-store, max-age=0, no-cache', $response->getHeaderLine('Cache-control')); @@ -218,7 +205,7 @@ public function testNoCache(): void public function testSetCache(): void { - $response = new Response(new App()); + $response = new Response(); $date = date('r'); @@ -238,18 +225,15 @@ public function testSetCache(): void public function testSetCacheNoOptions(): void { - $response = new Response(new App()); - - $options = []; - - $response->setCache($options); + $response = new Response(); + $response->setCache([]); $this->assertSame('no-store, max-age=0, no-cache', $response->getHeaderLine('Cache-Control')); } public function testSetLastModifiedWithDateTimeObject(): void { - $response = new Response(new App()); + $response = new Response(); $datetime = DateTime::createFromFormat('Y-m-d', '2000-03-10'); $response->setLastModified($datetime); @@ -264,8 +248,7 @@ public function testSetLastModifiedWithDateTimeObject(): void public function testRedirectSetsDefaultCodeAndLocationHeader(): void { - $response = new Response(new App()); - + $response = new Response(); $response->redirect('example.com'); $this->assertTrue($response->hasHeader('location')); @@ -286,7 +269,7 @@ public function testRedirect( ->setServer('SERVER_PROTOCOL', $protocol) ->setServer('REQUEST_METHOD', $method); - $response = new Response(new App()); + $response = new Response(); $response->redirect('example.com', 'auto', $code); $this->assertTrue($response->hasHeader('location')); @@ -330,7 +313,7 @@ public function testRedirectWithIIS( ->setServer('SERVER_PROTOCOL', 'HTTP/1.1') ->setServer('REQUEST_METHOD', 'POST'); - $response = new Response(new App()); + $response = new Response(); $response->redirect('example.com', 'auto', $code); $this->assertSame('0;url=example.com', $response->getHeaderLine('Refresh')); @@ -353,14 +336,14 @@ public static function provideRedirectWithIIS(): iterable public function testSetCookieFails(): void { - $response = new Response(new App()); + $response = new Response(); $this->assertFalse($response->hasCookie('foo')); } public function testSetCookieMatch(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar'); $this->assertTrue($response->hasCookie('foo')); @@ -369,7 +352,7 @@ public function testSetCookieMatch(): void public function testSetCookieFailDifferentPrefix(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar', '', '', '', 'ack'); $this->assertFalse($response->hasCookie('foo')); @@ -377,7 +360,7 @@ public function testSetCookieFailDifferentPrefix(): void public function testSetCookieSuccessOnPrefix(): void { - $response = new Response(new App()); + $response = new Response(); $response->setCookie('foo', 'bar', '', '', '', 'ack'); $this->assertTrue($response->hasCookie('foo', null, 'ack')); @@ -396,7 +379,7 @@ public function testJSONWithArray(): void ]; $expected = service('format')->getFormatter('application/json')->format($body); - $response = new Response(new App()); + $response = new Response(); $response->setJSON($body); $this->assertSame($expected, $response->getJSON()); @@ -415,7 +398,7 @@ public function testJSONGetFromNormalBody(): void ]; $expected = service('format')->getFormatter('application/json')->format($body); - $response = new Response(new App()); + $response = new Response(); $response->setBody($body); $this->assertSame($expected, $response->getJSON()); @@ -433,7 +416,7 @@ public function testXMLWithArray(): void ]; $expected = service('format')->getFormatter('application/xml')->format($body); - $response = new Response(new App()); + $response = new Response(); $response->setXML($body); $this->assertSame($expected, $response->getXML()); @@ -452,7 +435,7 @@ public function testXMLGetFromNormalBody(): void ]; $expected = service('format')->getFormatter('application/xml')->format($body); - $response = new Response(new App()); + $response = new Response(); $response->setBody($body); $this->assertSame($expected, $response->getXML()); @@ -460,7 +443,7 @@ public function testXMLGetFromNormalBody(): void public function testGetDownloadResponseByData(): void { - $response = new Response(new App()); + $response = new Response(); $actual = $response->download('unit-test.txt', 'data'); @@ -478,7 +461,7 @@ public function testGetDownloadResponseByData(): void public function testGetDownloadResponseByFilePath(): void { - $response = new Response(new App()); + $response = new Response(); $actual = $response->download(__FILE__, null); @@ -531,7 +514,7 @@ public function testGetDownloadResponseByExtremeFilePath(): void public function testVagueDownload(): void { - $response = new Response(new App()); + $response = new Response(); $actual = $response->download(); @@ -540,7 +523,7 @@ public function testVagueDownload(): void public function testPretendMode(): void { - $response = new MockResponse(new App()); + $response = new MockResponse(); $response->pretend(true); $this->assertTrue($response->getPretend()); $response->pretend(false); @@ -549,7 +532,7 @@ public function testPretendMode(): void public function testMisbehaving(): void { - $response = new MockResponse(new App()); + $response = new MockResponse(); $response->misbehave(); $this->expectException(HTTPException::class); @@ -561,7 +544,7 @@ public function testTemporaryRedirectHTTP11(): void service('superglobals') ->setServer('SERVER_PROTOCOL', 'HTTP/1.1') ->setServer('REQUEST_METHOD', 'POST'); - $response = new Response(new App()); + $response = new Response(); $response->setProtocolVersion('HTTP/1.1'); $response->redirect('/foo'); @@ -574,7 +557,7 @@ public function testTemporaryRedirectGetHTTP11(): void service('superglobals') ->setServer('SERVER_PROTOCOL', 'HTTP/1.1') ->setServer('REQUEST_METHOD', 'GET'); - $response = new Response(new App()); + $response = new Response(); $response->setProtocolVersion('HTTP/1.1'); $response->redirect('/foo'); @@ -588,7 +571,7 @@ public function testRedirectResponseCookies(): void { $loginTime = time(); - $response = new Response(new App()); + $response = new Response(); $answer1 = $response->redirect('/login') ->setCookie('foo', 'bar', YEAR) ->setCookie('login_time', (string) $loginTime, YEAR); @@ -600,7 +583,7 @@ public function testRedirectResponseCookies(): void // Make sure we don't blow up if pretending to send headers public function testPretendOutput(): void { - $response = new Response(new App()); + $response = new Response(); $response->pretend(true); $response->setBody('Happy days'); @@ -615,10 +598,7 @@ public function testPretendOutput(): void public function testSendRemovesDefaultNoncePlaceholdersWhenCSPDisabled(): void { - $config = new App(); - $config->CSPEnabled = false; - - $response = new Response($config); + $response = new Response(); $response->pretend(true); $body = ''; @@ -638,9 +618,6 @@ public function testSendRemovesDefaultNoncePlaceholdersWhenCSPDisabled(): void public function testSendRemovesCustomNoncePlaceholdersWhenCSPDisabled(): void { - $appConfig = new App(); - $appConfig->CSPEnabled = false; - // Create custom CSP config with custom nonce tags $cspConfig = new \Config\ContentSecurityPolicy(); $cspConfig->scriptNonceTag = '{custom-script-tag}'; @@ -648,7 +625,7 @@ public function testSendRemovesCustomNoncePlaceholdersWhenCSPDisabled(): void Services::injectMock('csp', new ContentSecurityPolicy($cspConfig)); - $response = new Response($appConfig); + $response = new Response(); $response->pretend(true); $body = ''; @@ -668,10 +645,7 @@ public function testSendRemovesCustomNoncePlaceholdersWhenCSPDisabled(): void public function testSendNoEffectWhenBodyEmptyAndCSPDisabled(): void { - $config = new App(); - $config->CSPEnabled = false; - - $response = new Response($config); + $response = new Response(); $response->pretend(true); $body = ''; @@ -687,10 +661,7 @@ public function testSendNoEffectWhenBodyEmptyAndCSPDisabled(): void public function testSendNoEffectWithNoPlaceholdersAndCSPDisabled(): void { - $config = new App(); - $config->CSPEnabled = false; - - $response = new Response($config); + $response = new Response(); $response->pretend(true); $body = 'Test

No placeholders here

'; @@ -707,10 +678,7 @@ public function testSendNoEffectWithNoPlaceholdersAndCSPDisabled(): void public function testSendRemovesMultiplePlaceholdersWhenCSPDisabled(): void { - $config = new App(); - $config->CSPEnabled = false; - - $response = new Response($config); + $response = new Response(); $response->pretend(true); $body = ''; @@ -732,16 +700,13 @@ public function testSendRemovesMultiplePlaceholdersWhenCSPDisabled(): void public function testSendRemovesPlaceholdersWhenBothCSPAndAutoNonceAreDisabled(): void { - $appConfig = new App(); - $appConfig->CSPEnabled = false; - // Create custom CSP config with custom nonce tags $cspConfig = new \Config\ContentSecurityPolicy(); $cspConfig->autoNonce = false; Services::injectMock('csp', new ContentSecurityPolicy($cspConfig)); - $response = new Response($appConfig); + $response = new Response(); $response->pretend(true); $body = ''; @@ -758,4 +723,95 @@ public function testSendRemovesPlaceholdersWhenBothCSPAndAutoNonceAreDisabled(): $this->assertStringContainsString('', $actual); $this->assertStringContainsString('', $actual); } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/8201 + */ + public function testConstructorDoesNotLoadCspAndCookieClasses(): void + { + $response = (new ReflectionClass(Response::class))->newInstance(); + + $this->assertNull( + $this->getPrivateProperty($response, 'CSP'), + 'CSP must be lazily instantiated, not loaded in Response::__construct().', + ); + $this->assertNull( + $this->getPrivateProperty($response, 'cookieStore'), + 'CookieStore must be lazily instantiated, not loaded in Response::__construct().', + ); + } + + public function testSendWithoutCspOrCookiesDoesNotLoadThoseClasses(): void + { + $response = new Response(); + $response->pretend(true); + $response->setBody('No CSP or cookies here.'); + + ob_start(); + $response->send(); + ob_end_clean(); + + $this->assertNull($this->getPrivateProperty($response, 'CSP')); + $this->assertNull($this->getPrivateProperty($response, 'cookieStore')); + } + + public function testGetCspLazilyInstantiatesCsp(): void + { + $response = new Response(); + + $this->assertNull($this->getPrivateProperty($response, 'CSP')); + + $csp = $response->getCSP(); + + $this->assertInstanceOf(ContentSecurityPolicy::class, $csp); + $this->assertSame($csp, $response->getCSP(), 'Subsequent getCSP() calls must return the same instance.'); + } + + public function testSetCookieLazilyInstantiatesCookieStore(): void + { + $response = new Response(); + + $this->assertNull($this->getPrivateProperty($response, 'cookieStore')); + + $response->setCookie('foo', 'bar'); + + $this->assertInstanceOf(CookieStore::class, $this->getPrivateProperty($response, 'cookieStore')); + $this->assertTrue($response->hasCookie('foo')); + } + + public function testGetCookiesReturnsEmptyArrayWhenCookieStoreNotInitialized(): void + { + $response = new Response(); + + $this->assertSame([], $response->getCookies()); + $this->assertNull( + $this->getPrivateProperty($response, 'cookieStore'), + 'getCookies() must not instantiate the store when no cookies have been set.', + ); + } + + public function testGetCookieStillUsesSetCookieDefaultsWhenStoreNotInitialized(): void + { + $oldDefaults = Cookie::setDefaults(); + + config('Cookie')->prefix = 'test_'; + + try { + $response = new Response(); + + $this->assertNull($this->getPrivateProperty($response, 'cookieStore')); + + $response->setCookie('foo', 'bar'); + + $this->assertInstanceOf(CookieStore::class, $this->getPrivateProperty($response, 'cookieStore')); + $this->assertTrue($response->hasCookie('foo')); + + $cookie = $response->getCookie('foo'); + $this->assertSame('test_', $cookie->getPrefix()); + $this->assertSame('test_foo', $cookie->getPrefixedName()); + } finally { + Cookie::setDefaults($oldDefaults); + Factories::reset('config'); + } + } } diff --git a/tests/system/HTTP/SSEResponseSendTest.php b/tests/system/HTTP/SSEResponseSendTest.php new file mode 100644 index 000000000000..c89d9148c14a --- /dev/null +++ b/tests/system/HTTP/SSEResponseSendTest.php @@ -0,0 +1,55 @@ + + * + * 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 PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; + +/** + * @internal + */ +#[Group('SeparateProcess')] +final class SSEResponseSendTest extends CIUnitTestCase +{ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[WithoutErrorHandler] + public function testSendEmitsHeadersCookiesAndStream(): void + { + $response = new SSEResponse(static function (SSEResponse $sse): void { + $sse->event('hello'); + }); + $response->pretend(false); + $response->setCookie('foo', 'bar'); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + $this->assertSame("data: hello\n\n", $output); + $this->assertHeaderEmitted('Content-Type: text/event-stream; charset=UTF-8'); + $this->assertHeaderEmitted('Cache-Control: no-cache'); + $this->assertHeaderEmitted('X-Accel-Buffering: no'); + $this->assertHeaderEmitted('Set-Cookie: foo=bar;'); + + if (version_compare($response->getProtocolVersion(), '2.0', '<')) { + $this->assertHeaderEmitted('Connection: keep-alive'); + } else { + $this->assertHeaderNotEmitted('Connection: keep-alive'); + } + } +} diff --git a/tests/system/HTTP/SSEResponseTest.php b/tests/system/HTTP/SSEResponseTest.php new file mode 100644 index 000000000000..193cf6ffae57 --- /dev/null +++ b/tests/system/HTTP/SSEResponseTest.php @@ -0,0 +1,180 @@ + + * + * 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 PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('SeparateProcess')] +final class SSEResponseTest extends CIUnitTestCase +{ + public function testEventFormatsLinesAndSanitizesFields(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->event("line1\nline2", "up\ndate", "1\n2"); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame( + "event: update\nid: 12\ndata: line1\ndata: line2\n\n", + $output, + ); + } + + public function testCommentFormatsAsSseCommentLines(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->comment("keep\nalive"); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame(": keep\n: alive\n\n", $output); + } + + public function testRetryFormatsRetryField(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->retry(1500); + $output = ob_get_clean(); + + $this->assertTrue($result); + $this->assertSame("retry: 1500\n\n", $output); + } + + public function testEventReturnsFalseOnJsonEncodeFailure(): void + { + $response = new SSEResponse(static function (): void { + }); + + $data = [ + 'bad' => "\xB1\x31", + ]; + + ob_start(); + $result = $response->event($data); + $output = ob_get_clean(); + + $this->assertFalse($result); + $this->assertSame('', $output); + } + + public function testEventWithStringDataOnly(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event('hello'); + $output = ob_get_clean(); + + $this->assertSame("data: hello\n\n", $output); + } + + public function testEventWithArrayDataJsonEncodes(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event(['key' => 'value']); + $output = ob_get_clean(); + + $this->assertSame("data: {\"key\":\"value\"}\n\n", $output); + } + + public function testEventWithEventNameOnly(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event('data', 'update'); + $output = ob_get_clean(); + + $this->assertSame("event: update\ndata: data\n\n", $output); + } + + public function testEventWithIdOnly(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event('data', null, '42'); + $output = ob_get_clean(); + + $this->assertSame("id: 42\ndata: data\n\n", $output); + } + + public function testEventNormalizesCarriageReturnLineFeed(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event("a\r\nb"); + $output = ob_get_clean(); + + $this->assertSame("data: a\ndata: b\n\n", $output); + } + + public function testEventNormalizesCarriageReturn(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->event("a\rb"); + $output = ob_get_clean(); + + $this->assertSame("data: a\ndata: b\n\n", $output); + } + + public function testCommentSingleLine(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $response->comment('hello'); + $output = ob_get_clean(); + + $this->assertSame(": hello\n\n", $output); + } + + public function testSendBodyIsNoOp(): void + { + $response = new SSEResponse(static function (): void { + }); + + ob_start(); + $result = $response->sendBody(); + $output = ob_get_clean(); + + $this->assertSame($response, $result); + $this->assertSame('', $output); + } +} diff --git a/tests/system/HTTP/SiteURITest.php b/tests/system/HTTP/SiteURITest.php index 1208edaf05b9..15e835b92d2c 100644 --- a/tests/system/HTTP/SiteURITest.php +++ b/tests/system/HTTP/SiteURITest.php @@ -13,7 +13,6 @@ namespace CodeIgniter\HTTP; -use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\Test\CIUnitTestCase; @@ -386,17 +385,6 @@ public function testSetSegmentOutOfRange(): void $uri->setSegment(4, 'four'); } - public function testSetSegmentSilentOutOfRange(): void - { - $config = new App(); - $uri = new SiteURI($config); - $uri->setPath('one/method'); - $uri->setSilent(); - - $uri->setSegment(4, 'four'); - $this->assertSame(['one', 'method'], $uri->getSegments()); - } - public function testSetSegmentZero(): void { $this->expectException(HTTPException::class); @@ -472,26 +460,6 @@ public function testGetTotalSegments(): void $this->assertSame(0, $uri->getTotalSegments()); } - public function testSetURI(): void - { - $this->expectException(BadMethodCallException::class); - - $config = new App(); - $uri = new SiteURI($config); - - $uri->setURI('http://another.site.example.jp/'); - } - - public function testSetBaseURI(): void - { - $this->expectException(BadMethodCallException::class); - - $config = new App(); - $uri = new SiteURI($config); - - $uri->setBaseURL('http://another.site.example.jp/'); - } - public function testGetBaseURL(): void { $config = new App(); diff --git a/tests/system/HTTP/URITest.php b/tests/system/HTTP/URITest.php index c449d13af65c..dc81928f1685 100644 --- a/tests/system/HTTP/URITest.php +++ b/tests/system/HTTP/URITest.php @@ -68,14 +68,6 @@ public function testSegmentOutOfRange(): void $uri->getSegment(5); } - public function testSegmentOutOfRangeWithSilent(): void - { - $url = 'http://abc.com/a123/b/c'; - $uri = new URI($url); - - $this->assertSame('', $uri->setSilent()->getSegment(22)); - } - public function testSegmentOutOfRangeWithDefaultValue(): void { $this->expectException(HTTPException::class); @@ -85,40 +77,6 @@ public function testSegmentOutOfRangeWithDefaultValue(): void $uri->getSegment(22, 'something'); } - public function testSegmentOutOfRangeWithSilentAndDefaultValue(): void - { - $url = 'http://abc.com/a123/b/c'; - $uri = new URI($url); - - $this->assertSame('something', $uri->setSilent()->getSegment(22, 'something')); - } - - public function testSegmentsWithDefaultValueAndSilent(): void - { - $uri = new URI('http://hostname/path/to'); - $uri->setSilent(); - - $this->assertSame(['path', 'to'], $uri->getSegments()); - $this->assertSame('path', $uri->getSegment(1)); - $this->assertSame('to', $uri->getSegment(2, 'different')); - $this->assertSame('script', $uri->getSegment(3, 'script')); - $this->assertSame('', $uri->getSegment(3)); - - $this->assertSame(2, $uri->getTotalSegments()); - } - - public function testSegmentOutOfRangeWithDefaultValuesAndSilent(): void - { - $uri = new URI('http://hostname/path/to/script'); - $uri->setSilent(); - - $this->assertSame('', $uri->getSegment(22)); - $this->assertSame('something', $uri->getSegment(33, 'something')); - - $this->assertSame(3, $uri->getTotalSegments()); - $this->assertSame(['path', 'to', 'script'], $uri->getSegments()); - } - public function testCanCastAsString(): void { $url = 'http://username:password@hostname:9090/path?arg=value#anchor'; @@ -222,34 +180,18 @@ public function testMissingScheme(): void public function testSchemeSub(): void { - $url = 'example.com'; - $uri = new URI('http://' . $url); - $uri->setScheme('x'); - - $this->assertSame('x://' . $url, (string) $uri); - } - - public function testSetSchemeSetsValue(): void - { - $url = 'http://example.com/path'; - $uri = new URI($url); - - $uri->setScheme('https'); + $uri = (new URI('http://example.com'))->withScheme('x'); - $this->assertSame('https', $uri->getScheme()); - $expected = 'https://example.com/path'; - $this->assertSame($expected, (string) $uri); + $this->assertSame('x://example.com', (string) $uri); } public function testWithScheme(): void { - $url = 'example.com'; - $uri = new URI('http://' . $url); - + $uri = new URI('http://example.com'); $new = $uri->withScheme('x'); - $this->assertSame('x://' . $url, (string) $new); - $this->assertSame('http://' . $url, (string) $uri); + $this->assertSame('x://example.com', (string) $new); + $this->assertSame('http://example.com', (string) $uri); } public function testWithSchemeSetsHttps(): void @@ -347,16 +289,6 @@ public function testSetPortInvalidValues(): void $uri->setPort(70000); } - public function testSetPortInvalidValuesSilent(): void - { - $url = 'http://example.com/path'; - $uri = new URI($url); - - $uri->setSilent()->setPort(70000); - - $this->assertNull($uri->getPort()); - } - public function testSetPortTooSmall(): void { $url = 'http://example.com/path'; @@ -383,9 +315,7 @@ public function testCatchesBadPort(): void { $this->expectException(HTTPException::class); - $url = 'http://username:password@hostname:90909/path?arg=value#anchor'; - $uri = new URI(); - $uri->setURI($url); + new URI('http://username:password@hostname:90909/path?arg=value#anchor'); } public function testSetPathSetsValue(): void @@ -547,7 +477,29 @@ public function testSetQuerySetsValue(): void $this->assertSame($expected, (string) $uri); } - public function testSetQuerySetsValueWithUseRawQueryString(): void + public function testWithQuerySetsQueryWithoutMutatingOriginal(): void + { + $url = 'http://example.com/path?foo=bar#fragment'; + $uri = new URI($url); + + $new = $uri->withQuery('?key=value&second.key=value.2'); + + $this->assertNotSame($uri, $new); + $this->assertSame('key=value&second_key=value.2', $new->getQuery()); + $this->assertSame('http://example.com/path?key=value&second_key=value.2#fragment', (string) $new); + $this->assertSame($url, (string) $uri); + } + + public function testUseRawQueryStringAtConstructor(): void + { + $url = 'http://example.com/path?key=value&second.key=value.2'; + $uri = new URI($url, true); + + $this->assertSame('key=value&second.key=value.2', $uri->getQuery()); + $this->assertSame($url, (string) $uri); + } + + public function testUseRawQueryStringAtSetter(): void { $url = 'http://example.com/path'; $uri = new URI($url); @@ -555,8 +507,7 @@ public function testSetQuerySetsValueWithUseRawQueryString(): void $uri->useRawQueryString()->setQuery('?key=value&second.key=value.2'); $this->assertSame('key=value&second.key=value.2', $uri->getQuery()); - $expected = 'http://example.com/path?key=value&second.key=value.2'; - $this->assertSame($expected, (string) $uri); + $this->assertSame('http://example.com/path?key=value&second.key=value.2', (string) $uri); } public function testSetQueryArraySetsValue(): void @@ -571,6 +522,32 @@ public function testSetQueryArraySetsValue(): void $this->assertSame($expected, (string) $uri); } + public function testWithQueryArraySetsQueryWithoutMutatingOriginal(): void + { + $url = 'http://example.com/path?foo=bar#fragment'; + $uri = new URI($url); + + $new = $uri->withQueryArray(['key' => 'value', 'second.key' => 'value.2']); + + $this->assertNotSame($uri, $new); + $this->assertSame('key=value&second_key=value.2', $new->getQuery()); + $this->assertSame('http://example.com/path?key=value&second_key=value.2#fragment', (string) $new); + $this->assertSame($url, (string) $uri); + } + + public function testWithQueryArraySetsQueryWithUseRawQueryStringWithoutMutatingOriginal(): void + { + $url = 'http://example.com/path?foo=bar#fragment'; + $uri = new URI($url, true); + + $new = $uri->withQueryArray(['key' => 'value', 'second.key' => 'value.2']); + + $this->assertNotSame($uri, $new); + $this->assertSame('key=value&second.key=value.2', $new->getQuery()); + $this->assertSame('http://example.com/path?key=value&second.key=value.2#fragment', (string) $new); + $this->assertSame($url, (string) $uri); + } + public function testSetQueryArraySetsValueWithUseRawQueryString(): void { $url = 'http://example.com/path'; @@ -593,14 +570,18 @@ public function testSetQueryThrowsErrorWhenFragmentPresent(): void $uri->setQuery('?key=value#fragment'); } - public function testSetQueryThrowsErrorWhenFragmentPresentSilent(): void + public function testWithQueryThrowsErrorWhenFragmentPresentWithoutMutatingOriginal(): void { - $url = 'http://example.com/path'; + $url = 'http://example.com/path?foo=bar'; $uri = new URI($url); - $uri->setSilent()->setQuery('?key=value#fragment'); + $this->expectException(HTTPException::class); - $this->assertSame('', $uri->getQuery()); + try { + $uri->withQuery('?key=value#fragment'); + } finally { + $this->assertSame($url, (string) $uri); + } } /** @@ -907,6 +888,57 @@ public function testAddQueryVarRespectsExistingQueryVars(): void $this->assertSame('http://example.com/foo?bar=baz&baz=foz', (string) $uri); } + public function testWithQueryVarAddsQueryVarWithoutMutatingOriginal(): void + { + $base = 'http://example.com/foo'; + $uri = new URI($base); + + $new = $uri->withQueryVar('bar', 'baz'); + + $this->assertNotSame($uri, $new); + $this->assertSame('http://example.com/foo?bar=baz', (string) $new); + $this->assertSame('http://example.com/foo', (string) $uri); + } + + public function testWithQueryVarReplacesQueryVarAndPreservesFragment(): void + { + $base = 'http://example.com/foo?bar=baz#section'; + $uri = new URI($base); + + $new = $uri->withQueryVar('bar', 'foz'); + + $this->assertNotSame($uri, $new); + $this->assertSame('http://example.com/foo?bar=foz#section', (string) $new); + $this->assertSame('http://example.com/foo?bar=baz#section', (string) $uri); + } + + public function testWithQueryVarKeepsEmptyStringQueryVar(): void + { + $base = 'http://example.com/foo?bar=baz'; + $uri = new URI($base); + + $new = $uri->withQueryVar('bar', ''); + + $this->assertNotSame($uri, $new); + $this->assertSame('http://example.com/foo?bar=', (string) $new); + $this->assertSame('http://example.com/foo?bar=baz', (string) $uri); + } + + public function testWithQueryVarsAddsAndReplacesWithoutMutatingOriginal(): void + { + $base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz#section'; + $uri = new URI($base); + + $new = $uri->withQueryVars([ + 'baz' => 'updated', + 'new' => 'value', + ]); + + $this->assertNotSame($uri, $new); + $this->assertSame('http://example.com/foo?foo=bar&bar=baz&baz=updated&new=value#section', (string) $new); + $this->assertSame('http://example.com/foo?foo=bar&bar=baz&baz=foz#section', (string) $uri); + } + public function testStripQueryVars(): void { $base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz'; @@ -917,6 +949,18 @@ public function testStripQueryVars(): void $this->assertSame('http://example.com/foo?foo=bar', (string) $uri); } + public function testWithoutQueryVarsRemovesQueryVarsWithoutMutatingOriginal(): void + { + $base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz#section'; + $uri = new URI($base); + + $new = $uri->withoutQueryVars('bar', 'baz'); + + $this->assertNotSame($uri, $new); + $this->assertSame('http://example.com/foo?foo=bar#section', (string) $new); + $this->assertSame($base, (string) $uri); + } + public function testKeepQueryVars(): void { $base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz'; @@ -927,6 +971,18 @@ public function testKeepQueryVars(): void $this->assertSame('http://example.com/foo?bar=baz&baz=foz', (string) $uri); } + public function testWithOnlyQueryVarsKeepsQueryVarsWithoutMutatingOriginal(): void + { + $base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz#section'; + $uri = new URI($base); + + $new = $uri->withOnlyQueryVars('bar', 'baz'); + + $this->assertNotSame($uri, $new); + $this->assertSame('http://example.com/foo?bar=baz&baz=foz#section', (string) $new); + $this->assertSame($base, (string) $uri); + } + public function testEmptyQueryVars(): void { $base = 'http://example.com/foo'; @@ -1034,17 +1090,6 @@ public function testSetBadSegment(): void $uri->setSegment(6, 'banana'); } - public function testSetBadSegmentSilent(): void - { - $base = 'http://example.com/foo/bar/baz'; - $uri = new URI($base); - $segments = $uri->getSegments(); - - $uri->setSilent()->setSegment(6, 'banana'); - - $this->assertSame($segments, $uri->getSegments()); - } - // Exploratory testing, investigating https://github.com/codeigniter4/CodeIgniter4/issues/2016 public function testBasedNoIndex(): void @@ -1176,23 +1221,10 @@ public function testEmptyURIPath(): void public function testSetURI(): void { - $url = ':'; - $uri = new URI(); - $this->expectException(HTTPException::class); - $this->expectExceptionMessage(lang('HTTP.cannotParseURI', [$url])); - - $uri->setURI($url); - } - - public function testSetURISilent(): void - { - $url = ':'; - $uri = new URI(); - - $uri->setSilent()->setURI($url); + $this->expectExceptionMessage(lang('HTTP.cannotParseURI', [':'])); - $this->assertTrue(true); + new URI(':'); } public function testCreateURIStringNoArguments(): void diff --git a/tests/system/Helpers/Array/ArrayHelperDotHasTest.php b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php new file mode 100644 index 000000000000..4b8b4751123a --- /dev/null +++ b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Helpers\Array; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ArrayHelperDotHasTest extends CIUnitTestCase +{ + private array $array = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred Flinstone', 'age' => 20], + ['age' => 21], // 'name' key does not exist + ], + ], + ]; + + public function testDotHas(): void + { + $this->assertFalse(ArrayHelper::dotHas('', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('not', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.friends', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('not.friends', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.friends.0.name', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('contacts.friends.1.name', $this->array)); + } + + public function testDotHasWithEndingWildCard(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*"'); + + $this->assertTrue(ArrayHelper::dotHas('contacts.*', $this->array)); + } + + public function testDotHasWithDoubleWildCard(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*.*.age"'); + + $this->assertTrue(ArrayHelper::dotHas('contacts.*.*.age', $this->array)); + } + + public function testDotHasWithWildCard(): void + { + $this->assertTrue(ArrayHelper::dotHas('*.friends', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.friends.*.age', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('contacts.friends.*.name', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('*.friends.*.age', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('*.friends.*.name', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.*.0.age', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.*.1.age', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.*.0.name', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('contacts.*.1.name', $this->array)); + } +} diff --git a/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php b/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php deleted file mode 100644 index 0641c41b2bd0..000000000000 --- a/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php +++ /dev/null @@ -1,74 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Helpers\Array; - -use CodeIgniter\Exceptions\InvalidArgumentException; -use CodeIgniter\Test\CIUnitTestCase; -use PHPUnit\Framework\Attributes\Group; - -/** - * @internal - */ -#[Group('Others')] -final class ArrayHelperDotKeyExistsTest extends CIUnitTestCase -{ - private array $array = [ - 'contacts' => [ - 'friends' => [ - ['name' => 'Fred Flinstone', 'age' => 20], - ['age' => 21], // 'name' key does not exist - ], - ], - ]; - - public function testDotKeyExists(): void - { - $this->assertFalse(ArrayHelper::dotKeyExists('', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('not', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.friends', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('not.friends', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.friends.0.name', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('contacts.friends.1.name', $this->array)); - } - - public function testDotKeyExistsWithEndingWildCard(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*"'); - - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*', $this->array)); - } - - public function testDotKeyExistsWithDoubleWildCard(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*.*.age"'); - - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.*.age', $this->array)); - } - - public function testDotKeyExistsWithWildCard(): void - { - $this->assertTrue(ArrayHelper::dotKeyExists('*.friends', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.friends.*.age', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('contacts.friends.*.name', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('*.friends.*.age', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('*.friends.*.name', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.0.age', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.1.age', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.0.name', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('contacts.*.1.name', $this->array)); - } -} diff --git a/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php new file mode 100644 index 000000000000..5560703344c9 --- /dev/null +++ b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php @@ -0,0 +1,490 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Helpers\Array; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ArrayHelperDotModifyTest extends CIUnitTestCase +{ + public function testDotSetCreatesNestedPath(): void + { + $array = []; + + ArrayHelper::dotSet($array, 'user.profile.id', 123); + + $this->assertSame(['user' => ['profile' => ['id' => 123]]], $array); + } + + public function testDotSetOverwritesLeafValue(): void + { + $array = ['user' => ['profile' => ['id' => 123]]]; + + ArrayHelper::dotSet($array, 'user.profile.id', 456); + + $this->assertSame(456, $array['user']['profile']['id']); + } + + public function testDotSetWithEscapedDotKey(): void + { + $array = []; + + ArrayHelper::dotSet($array, 'config.api\.version', 'v1'); + + $this->assertSame('v1', $array['config']['api.version']); + } + + /** + * @param array $array + */ + #[DataProvider('provideDotHas')] + public function testDotHas(string $index, array $array, bool $expected): void + { + $this->assertSame($expected, ArrayHelper::dotHas($index, $array)); + } + + /** + * @return iterable, expected: bool}> + */ + public static function provideDotHas(): iterable + { + yield from [ + 'null value at leaf' => [ + 'index' => 'user.nickname', + 'array' => ['user' => ['nickname' => null]], + 'expected' => true, + ], + 'path does not exist' => [ + 'index' => 'user.email', + 'array' => ['user' => ['id' => 123]], + 'expected' => false, + ], + 'non-existent numeric key' => [ + 'index' => '0.name', + 'array' => ['other' => 'x'], + 'expected' => false, + ], + 'existing numeric key' => [ + 'index' => '0.name', + 'array' => [['name' => 'a']], + 'expected' => true, + ], + 'zero value at leaf' => [ + 'index' => 'user.score', + 'array' => ['user' => ['score' => 0]], + 'expected' => true, + ], + 'string zero at leaf' => [ + 'index' => 'user.code', + 'array' => ['user' => ['code' => '0']], + 'expected' => true, + ], + 'escaped dot in key' => [ + 'index' => 'config.api\.version', + 'array' => ['config' => ['api.version' => 'v1']], + 'expected' => true, + ], + 'escaped dot key does not exist' => [ + 'index' => 'config.api\.version', + 'array' => ['config' => ['api' => ['version' => 'v1']]], + 'expected' => false, + ], + ]; + } + + public function testDotHasSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $this->assertTrue(ArrayHelper::dotHas('users.*.id', $array)); + $this->assertFalse(ArrayHelper::dotHas('users.*.email', $array)); + } + + public function testDotSetSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + ArrayHelper::dotSet($array, 'users.*.role', 'member'); + + $this->assertSame('member', $array['users'][0]['role']); + $this->assertSame('member', $array['users'][1]['role']); + } + + public function testDotSetSupportsWildcardSkipsNonArrayElements(): void + { + $array = [ + 'users' => [ + ['name' => 'a'], + 'invalid-entry', + ['name' => 'b'], + ], + ]; + + ArrayHelper::dotSet($array, 'users.*.role', 'member'); + + $this->assertSame('member', $array['users'][0]['role']); + $this->assertSame('invalid-entry', $array['users'][1]); + $this->assertSame('member', $array['users'][2]['role']); + } + + public function testDotUnsetRemovesNestedValue(): void + { + $array = ['user' => ['profile' => ['id' => 123, 'name' => 'john']]]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'user.profile.id')); + + $this->assertFalse(ArrayHelper::dotHas('user.profile.id', $array)); + $this->assertSame('john', $array['user']['profile']['name']); + } + + public function testDotUnsetIsNoOpWhenPathDoesNotExist(): void + { + $array = ['user' => ['id' => 123]]; + + $this->assertFalse(ArrayHelper::dotUnset($array, 'user.profile.id')); + + $this->assertSame(['user' => ['id' => 123]], $array); + } + + public function testDotUnsetWithEscapedDotKey(): void + { + $array = ['config' => ['api.version' => 'v1', 'region' => 'eu']]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'config.api\.version')); + + $this->assertSame(['config' => ['region' => 'eu']], $array); + } + + public function testDotUnsetSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'users.*.id')); + $this->assertFalse(ArrayHelper::dotHas('users.*.id', $array)); + $this->assertSame('a', $array['users'][0]['name']); + $this->assertSame('b', $array['users'][1]['name']); + } + + public function testDotUnsetSupportsWildcardReturnsFalseWhenNoKeysRemoved(): void + { + $array = [ + 'users' => [ + ['name' => 'a'], + ['name' => 'b'], + ], + ]; + + $this->assertFalse(ArrayHelper::dotUnset($array, 'users.*.id')); + } + + public function testDotUnsetSupportsEndingWildcard(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'user.*')); + $this->assertSame(['user' => [], 'meta' => ['request_id' => 'abc']], $array); + } + + public function testDotUnsetWithSingleWildcardClearsWholeArray(): void + { + $array = [ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertTrue(ArrayHelper::dotUnset($array, '*')); + $this->assertSame([], $array); + } + + public function testDotSetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*"'); + + $array = []; + ArrayHelper::dotSet($array, 'users.*', 1); + } + + public function testDotHasThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*"'); + + ArrayHelper::dotHas('users.*', []); + } + + public function testDotUnsetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*.*.id"'); + + $array = []; + ArrayHelper::dotUnset($array, 'users.*.*.id'); + } + + public function testDotOnlyReturnsNestedStructureForSinglePath(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'user.id')); + } + + public function testDotOnlyMergesMultiplePaths(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + 'email' => 'john@example.com', + ], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, ['user.id', 'user.name'])); + } + + public function testDotOnlySupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $expected = [ + 'users' => [ + ['id' => 1], + ['id' => 2], + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'users.*.id')); + } + + public function testDotOnlySupportsEndingWildcard(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'user.*')); + } + + public function testDotOnlySupportsEscapedDotKey(): void + { + $array = [ + 'config' => [ + 'api.version' => 'v1', + 'region' => 'eu', + ], + ]; + + $expected = [ + 'config' => [ + 'api.version' => 'v1', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'config.api\.version')); + } + + public function testDotExceptRemovesNestedPath(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'user.id')); + } + + public function testDotExceptSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $expected = [ + 'users' => [ + ['name' => 'a'], + ['name' => 'b'], + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'users.*.id')); + } + + public function testDotExceptSupportsEndingWildcard(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'user.*')); + } + + public function testDotExceptWithEscapedDotKey(): void + { + $array = [ + 'config' => [ + 'api.version' => 'v1', + 'region' => 'eu', + ], + ]; + + $expected = [ + 'config' => [ + 'region' => 'eu', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'config.api\.version')); + } + + public function testDotOnlyWithSingleWildcardReturnsWholeArray(): void + { + $array = [ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($array, ArrayHelper::dotOnly($array, '*')); + } + + public function testDotExceptWithSingleWildcardReturnsEmptyArray(): void + { + $array = [ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame([], ArrayHelper::dotExcept($array, '*')); + } + + public function testDotSetWithNumericKey(): void + { + $array = [['name' => 'a'], ['name' => 'b']]; + + ArrayHelper::dotSet($array, '0.name', 'x'); + + $this->assertSame('x', $array[0]['name']); + $this->assertSame('b', $array[1]['name']); + } + + public function testDotUnsetWithNumericKey(): void + { + $array = [['name' => 'a', 'role' => 'admin'], ['name' => 'b']]; + + $this->assertTrue(ArrayHelper::dotUnset($array, '0.role')); + $this->assertFalse(ArrayHelper::dotHas('0.role', $array)); + $this->assertSame('a', $array[0]['name']); + } + + public function testDotOnlyWithNumericKey(): void + { + $array = [['name' => 'a', 'role' => 'admin'], ['name' => 'b']]; + + $expected = [['name' => 'a']]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, '0.name')); + } + + public function testDotExceptWithNumericKey(): void + { + $array = [['name' => 'a', 'role' => 'admin'], ['name' => 'b']]; + + $expected = [['name' => 'a'], ['name' => 'b']]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, '0.role')); + } +} diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index 0293cc6c826e..826a376aa081 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -13,9 +13,14 @@ namespace CodeIgniter\Helpers; +use ArrayObject; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use stdClass; +use Tests\Support\SomeEntity; use ValueError; /** @@ -41,6 +46,334 @@ public function testArrayDotSimple(): void $this->assertSame(23, dot_array_search('foo.bar', $data)); } + public function testDotArraySetAndHas(): void + { + $data = []; + + dot_array_set($data, 'foo.bar', 23); + + $this->assertSame(['foo' => ['bar' => 23]], $data); + $this->assertTrue(dot_array_has('foo.bar', $data)); + } + + public function testDotArraySetWithWildcard(): void + { + $data = [ + 'foo' => [ + ['bar' => 23], + ['bar' => 42], + ], + ]; + + dot_array_set($data, 'foo.*.baz', 99); + + $this->assertSame(99, $data['foo'][0]['baz']); + $this->assertSame(99, $data['foo'][1]['baz']); + } + + public function testDotArrayHasSupportsWildcard(): void + { + $data = [ + 'foo' => [ + ['bar' => 23], + ['bar' => 42], + ], + ]; + + $this->assertTrue(dot_array_has('foo.*.bar', $data)); + } + + public function testDotArrayUnset(): void + { + $data = ['foo' => ['bar' => 23, 'baz' => 42]]; + + $this->assertTrue(dot_array_unset($data, 'foo.bar')); + + $this->assertFalse(dot_array_has('foo.bar', $data)); + $this->assertTrue(dot_array_has('foo.baz', $data)); + } + + public function testDotArrayUnsetWithWildcard(): void + { + $data = [ + 'foo' => [ + ['bar' => 23, 'baz' => 1], + ['bar' => 42, 'baz' => 2], + ], + ]; + + $this->assertTrue(dot_array_unset($data, 'foo.*.bar')); + $this->assertFalse(dot_array_has('foo.*.bar', $data)); + $this->assertTrue(dot_array_has('foo.*.baz', $data)); + } + + public function testDotArrayUnsetSupportsEndingWildcard(): void + { + $data = [ + 'foo' => [ + 'bar' => 23, + 'baz' => 42, + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertTrue(dot_array_unset($data, 'foo.*')); + $this->assertSame(['foo' => [], 'meta' => ['request_id' => 'abc']], $data); + } + + public function testDotArraySetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*"'); + + $data = []; + dot_array_set($data, 'users.*', 'member'); + } + + public function testDotArrayUnsetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*.*.id"'); + + $data = []; + dot_array_unset($data, 'users.*.*.id'); + } + + public function testDotArrayOnly(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + ], + ]; + + $this->assertSame($expected, dot_array_only($data, 'user.id')); + } + + public function testDotArrayOnlySupportsEndingWildcard(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + ]; + + $this->assertSame($expected, dot_array_only($data, 'user.*')); + } + + public function testDotArrayOnlyWithObjectValues(): void + { + $data = (object) [ + 'user' => (object) [ + 'id' => 123, + 'profile' => (object) [ + 'name' => 'john', + ], + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'profile' => [ + 'name' => 'john', + ], + ], + ]; + + $this->assertSame($expected, dot_array_only($data, 'user.profile.name')); + } + + public function testDotArrayOnlyWildcardWithEntityRows(): void + { + $a = new SomeEntity(); + $a->foo = 1; + $a->bar = 2; + + $b = new SomeEntity(); + $b->foo = 3; + $b->bar = 4; + + $this->assertSame( + [ + 'rows' => [ + ['foo' => 1], + ['foo' => 3], + ], + ], + dot_array_only(['rows' => [$a, $b]], 'rows.*.foo'), + ); + } + + public function testDotArrayOnlyPreservesWholeSelectedObject(): void + { + $user = (object) ['id' => 1, 'name' => 'Ada']; + + // Selecting the object as a whole returns it untouched. + $this->assertSame(['user' => $user], dot_array_only(['user' => $user], 'user')); + } + + public function testDotArrayOnlyProjectsPartialObjectAsArray(): void + { + $created = new DateTimeImmutable(); + + $data = [ + 'user' => (object) [ + 'id' => 123, + 'created' => $created, + ], + ]; + + // A partial projection must fabricate array structure for "user"... + $this->assertSame(['user' => ['id' => 123]], dot_array_only($data, 'user.id')); + + // ...while the value-object leaf is preserved as-is. + $this->assertSame(['user' => ['created' => $created]], dot_array_only($data, 'user.created')); + } + + public function testDotArrayExcept(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.id')); + } + + public function testDotArrayExceptSupportsEndingWildcard(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.*')); + } + + public function testDotArrayExceptWithObjectValues(): void + { + $meta = (object) ['request_id' => 'abc']; + $data = (object) [ + 'user' => (object) [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => $meta, + ]; + + $result = dot_array_except($data, 'user.id'); + + // "user" is partially excluded, so it is rebuilt as an array... + $this->assertSame(['name' => 'john'], $result['user']); + // ...but the untouched "meta" object is preserved as-is. + $this->assertSame($meta, $result['meta']); + } + + public function testDotArrayExceptWildcardWithObjectValues(): void + { + $data = (object) [ + 'user' => (object) [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.*')); + } + + public function testDotArrayExceptPreservesUntouchedObject(): void + { + $user = (object) ['id' => 1, 'name' => 'Ada']; + + // The path does not touch "user", so the object is returned untouched. + $this->assertSame(['user' => $user], dot_array_except(['user' => $user], 'other')); + } + + public function testDotArrayExceptPreservesValueObjects(): void + { + $created = new DateTimeImmutable(); + + $data = [ + 'user' => ['id' => 1], + 'created' => $created, + ]; + + $result = dot_array_except($data, 'user.id'); + + $this->assertSame([], $result['user']); + // Untouched value-objects must survive instead of collapsing to []. + $this->assertSame($created, $result['created']); + } + + public function testDotArrayExceptPreservesTraversableKeys(): void + { + $data = new ArrayObject([ + 'user' => ['id' => 1, 'name' => 'Ada'], + 'meta' => 'm', + ]); + + $expected = [ + 'user' => ['name' => 'Ada'], + 'meta' => 'm', + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.id')); + } + + public function testDotArrayOnlyAndExceptAgreeOnPublicPropertyObjects(): void + { + $user = new class () { + public int $id = 1; + public string $name = 'Ada'; + }; + + // Both helpers treat a public-property object as a container. + $this->assertSame(['user' => ['id' => 1]], dot_array_only(['user' => $user], 'user.id')); + $this->assertSame(['user' => ['name' => 'Ada']], dot_array_except(['user' => $user], 'user.id')); + } + public function testArrayDotTooManyLevels(): void { $data = [ @@ -210,6 +543,156 @@ public function testArrayDotIgnoresLastWildcard(): void $this->assertSame(['baz' => 23], dot_array_search('foo.bar.*', $data)); } + public function testArrayDotWithObjectValues(): void + { + $data = [ + 'user' => (object) [ + 'profile' => (object) [ + 'name' => 'Jane', + ], + ], + ]; + + $this->assertSame('Jane', dot_array_search('user.profile.name', $data)); + } + + public function testArrayDotWithMagicObjectValues(): void + { + $data = [ + 'user' => new class () { + /** + * @var array> + */ + private array $values = [ + 'profile' => [ + 'name' => 'Jane', + ], + ]; + + public function __isset(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function __get(string $key): mixed + { + return $this->values[$key]; + } + }, + ]; + + $this->assertSame('Jane', dot_array_search('user.profile.name', $data)); + } + + public function testArrayDotWithArrayAccessValues(): void + { + $data = [ + 'user' => new ArrayObject([ + 'profile' => [ + 'name' => 'Jane', + ], + ]), + ]; + + $this->assertSame('Jane', dot_array_search('user.profile.name', $data)); + } + + public function testArrayDotWithEntityValues(): void + { + $entity = new SomeEntity(); + $entity->foo = 'value'; + + $this->assertSame('value', dot_array_search('user.foo', ['user' => $entity])); + } + + public function testArrayDotWildcardWithObjectValues(): void + { + $data = [ + 'users' => [ + (object) ['name' => 'John'], + (object) ['name' => 'Maria'], + ], + ]; + + $this->assertSame(['John', 'Maria'], dot_array_search('users.*.name', $data)); + } + + public function testDotArrayHasWithObjectValues(): void + { + $data = [ + 'user' => (object) [ + 'profile' => (object) [ + 'name' => 'Jane', + ], + ], + ]; + + $this->assertTrue(dot_array_has('user.profile.name', $data)); + $this->assertFalse(dot_array_has('user.profile.email', $data)); + } + + public function testDotArrayHasWithMagicObjectValues(): void + { + $data = [ + 'user' => new class () { + /** + * @var array> + */ + private array $values = [ + 'profile' => [ + 'name' => 'Jane', + ], + ]; + + public function __isset(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function __get(string $key): mixed + { + return $this->values[$key]; + } + }, + ]; + + $this->assertTrue(dot_array_has('user.profile.name', $data)); + } + + public function testDotArrayHasWithArrayAccessValues(): void + { + $data = [ + 'user' => new ArrayObject([ + 'profile' => [ + 'name' => 'Jane', + ], + ]), + ]; + + $this->assertTrue(dot_array_has('user.profile.name', $data)); + } + + public function testDotArrayHasWithEntityValues(): void + { + $entity = new SomeEntity(); + $entity->foo = 'value'; + + $this->assertTrue(dot_array_has('user.foo', ['user' => $entity])); + $this->assertFalse(dot_array_has('user._options', ['user' => $entity])); + } + + public function testDotArrayHasWildcardWithEntityValues(): void + { + $a = new SomeEntity(); + $a->foo = 1; + + $b = new SomeEntity(); + $b->foo = 2; + + $this->assertTrue(dot_array_has('rows.*.foo', ['rows' => [$a, $b]])); + $this->assertFalse(dot_array_has('rows.*._cast', ['rows' => [$a, $b]])); + } + /** * @param int|string $key * @param array|string|null $expected @@ -274,6 +757,62 @@ public function testArrayDeepSearchReturnNullEmptyArray(): void $this->assertNull(array_deep_search('key644', $data)); } + /** + * @param array $data + */ + #[DataProvider('provideDotArrayHas')] + public function testDotArrayHas(string $index, array $data, bool $expected): void + { + $this->assertSame($expected, dot_array_has($index, $data)); + } + + /** + * @return iterable, expected: bool}> + */ + public static function provideDotArrayHas(): iterable + { + yield from [ + 'non-existent numeric key' => [ + 'index' => '0.name', + 'data' => ['other' => 'x'], + 'expected' => false, + ], + 'existing numeric key' => [ + 'index' => '0.name', + 'data' => [['name' => 'a']], + 'expected' => true, + ], + 'null value at leaf' => [ + 'index' => 'user.score', + 'data' => ['user' => ['score' => null]], + 'expected' => true, + ], + 'zero value at leaf' => [ + 'index' => 'user.score', + 'data' => ['user' => ['score' => 0]], + 'expected' => true, + ], + 'escaped dot in key' => [ + 'index' => 'config.api\.version', + 'data' => ['config' => ['api.version' => 'v1']], + 'expected' => true, + ], + ]; + } + + public function testDotArraySetAndUnsetWithNumericKey(): void + { + $data = [['name' => 'a'], ['name' => 'b']]; + + dot_array_set($data, '0.role', 'admin'); + + $this->assertSame('admin', $data[0]['role']); + $this->assertFalse(dot_array_has('1.role', $data)); + + $this->assertTrue(dot_array_unset($data, '0.role')); + $this->assertFalse(dot_array_has('0.role', $data)); + } + #[DataProvider('provideSortByMultipleKeys')] public function testArraySortByMultipleKeysWithArray(array $data, array $sortColumns, array $expected): void { @@ -1274,4 +1813,92 @@ public static function provideArrayGroupByExcludeEmpty(): iterable ], ]; } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/10225 + */ + public function testArrayGroupByWithObjectRows(): void + { + $json = <<<'JSON' + [ + { "id": 1, "name": "Giraffe", "group": "Mammals" }, + { "id": 2, "name": "Zebra", "group": "Mammals" }, + { "id": 3, "name": "Crow", "group": "Birds" } + ] + JSON; + $data = json_decode($json); + + $this->assertIsArray($data); + + $actual = array_group_by($data, ['group']); + + $this->assertSame( + [ + 'Mammals' => [$data[0], $data[1]], + 'Birds' => [$data[2]], + ], + $actual, + ); + $this->assertInstanceOf(stdClass::class, $actual['Mammals'][0]); + } + + public function testArrayGroupByWithNestedObjectRows(): void + { + $data = [ + (object) [ + 'id' => 1, + 'hr' => (object) [ + 'department' => 'Engineering', + ], + ], + (object) [ + 'id' => 2, + 'hr' => (object) [ + 'department' => 'Marketing', + ], + ], + (object) [ + 'id' => 3, + 'hr' => (object) [ + 'department' => 'Engineering', + ], + ], + ]; + + $actual = array_group_by($data, ['hr.department']); + + $this->assertSame( + [ + 'Engineering' => [$data[0], $data[2]], + 'Marketing' => [$data[1]], + ], + $actual, + ); + } + + public function testArrayGroupByWithEntityRows(): void + { + $a = new SomeEntity(); + $a->foo = 'A'; + $a->bar = 'x'; + + $b = new SomeEntity(); + $b->foo = 'B'; + $b->bar = 'y'; + + $c = new SomeEntity(); + $c->foo = 'A'; + $c->bar = 'z'; + + $actual = array_group_by([$a, $b, $c], ['foo']); + + $this->assertSame( + [ + 'A' => [$a, $c], + 'B' => [$b], + ], + $actual, + ); + $this->assertSame($a, $actual['A'][0]); + } } diff --git a/tests/system/Helpers/CookieHelperTest.php b/tests/system/Helpers/CookieHelperTest.php index 9b79e8d61dbc..b3cfeeae25f4 100644 --- a/tests/system/Helpers/CookieHelperTest.php +++ b/tests/system/Helpers/CookieHelperTest.php @@ -51,7 +51,7 @@ protected function setUp(): void $this->value = 'hello world'; $this->expire = 9999; - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $this->response = service('response'); $request = new IncomingRequest(new App(), new SiteURI(new App()), null, new UserAgent()); Services::injectMock('request', $request); diff --git a/tests/system/I18n/TimeLegacyTest.php b/tests/system/I18n/TimeLegacyTest.php index 8fbd359fbfd5..ecc62eee83d1 100644 --- a/tests/system/I18n/TimeLegacyTest.php +++ b/tests/system/I18n/TimeLegacyTest.php @@ -923,6 +923,74 @@ public function testAfter(): void $this->assertTrue($time2->isAfter($time1)); } + public function testBetweenInclusive(): void + { + $time = new TimeLegacy('2024-01-01 12:00:00.123456'); + $start = new TimeLegacy('2024-01-01 12:00:00.123456'); + $end = new TimeLegacy('2024-01-01 12:00:01.000000'); + + $this->assertTrue($time->between($start, $end)); + } + + public function testBetweenExclusive(): void + { + $time = new TimeLegacy('2024-01-01 12:00:00.123456'); + $start = new TimeLegacy('2024-01-01 12:00:00.123456'); + $end = new TimeLegacy('2024-01-01 12:00:01.000000'); + + $this->assertFalse($time->between($start, $end, false)); + } + + public function testBetweenSwapsReversedBounds(): void + { + $time = TimeLegacy::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->between('2024-01-01 13:00:00', '2024-01-01 12:00:00')); + } + + public function testBetweenSupportsTimezoneForStringInputs(): void + { + $time = TimeLegacy::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->between('2024-01-01 13:00:00', '2024-01-01 14:00:00', true, 'Europe/Warsaw')); + } + + public function testMinWithNullUsesNow(): void + { + TimeLegacy::setTestNow('2024-01-01 12:00:00', 'UTC'); + + $past = TimeLegacy::parse('2024-01-01 11:59:59', 'UTC'); + $future = TimeLegacy::parse('2024-01-01 12:00:01', 'UTC'); + + $this->assertSame($past, $past->min()); + $this->assertTrue($future->min()->sameAs(TimeLegacy::now('UTC'))); + } + + public function testMinSupportsTimezoneForStringInputs(): void + { + $time = TimeLegacy::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->min('2024-01-01 13:00:00', 'Europe/Warsaw')->sameAs('2024-01-01 13:00:00', 'Europe/Warsaw')); + } + + public function testMaxWithNullUsesNow(): void + { + TimeLegacy::setTestNow('2024-01-01 12:00:00', 'UTC'); + + $past = TimeLegacy::parse('2024-01-01 11:59:59', 'UTC'); + $future = TimeLegacy::parse('2024-01-01 12:00:01', 'UTC'); + + $this->assertTrue($past->max()->sameAs(TimeLegacy::now('UTC'))); + $this->assertSame($future, $future->max()); + } + + public function testMaxSupportsTimezoneForStringInputs(): void + { + $time = TimeLegacy::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->max('2024-01-01 13:00:00', 'Europe/Warsaw')->sameAs($time)); + } + public function testHumanizeYearsSingle(): void { TimeLegacy::setTestNow('March 10, 2017', 'America/Chicago'); diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index a7346c181b93..6ffc1dae9f59 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -1047,6 +1047,128 @@ public function testAfterWithMicroseconds(): void $this->assertFalse($time2->isAfter($time1)); } + public function testBetweenInclusive(): void + { + $time = new Time('2024-01-01 12:00:00.123456'); + $start = new Time('2024-01-01 12:00:00.123456'); + $end = new Time('2024-01-01 12:00:01.000000'); + + $this->assertTrue($time->between($start, $end)); + } + + public function testBetweenExclusive(): void + { + $time = new Time('2024-01-01 12:00:00.123456'); + $start = new Time('2024-01-01 12:00:00.123456'); + $end = new Time('2024-01-01 12:00:01.000000'); + + $this->assertFalse($time->between($start, $end, false)); + } + + public function testBetweenSwapsReversedBounds(): void + { + $time = Time::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->between('2024-01-01 13:00:00', '2024-01-01 12:00:00')); + } + + public function testBetweenSupportsTimezoneForStringInputs(): void + { + $time = Time::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->between('2024-01-01 13:00:00', '2024-01-01 14:00:00', true, 'Europe/Warsaw')); + } + + public function testBetweenSupportsDateTimeImmutable(): void + { + $time = Time::parse('2024-01-01 12:30:00', 'UTC'); + $start = new DateTimeImmutable('2024-01-01 12:00:00', new DateTimeZone('UTC')); + $end = new DateTimeImmutable('2024-01-01 13:00:00', new DateTimeZone('UTC')); + + $this->assertTrue($time->between($start, $end)); + } + + public function testGetUTCObjectPreservesDateTimeImmutable(): void + { + $time = Time::parse('2024-01-01 12:30:00', 'Europe/Warsaw'); + $immutable = new DateTimeImmutable('2024-01-01 13:30:00', new DateTimeZone('Europe/Warsaw')); + $utcTime = $time->getUTCObject($immutable); + + $this->assertInstanceOf(DateTimeImmutable::class, $utcTime); + $this->assertSame('UTC', $utcTime->getTimezone()->getName()); + $this->assertSame('2024-01-01 12:30:00.000000', $utcTime->format('Y-m-d H:i:s.u')); + } + + public function testMinReturnsEarlierTime(): void + { + $time = new Time('2024-01-01 12:00:00'); + $later = new Time('2024-01-01 12:00:01'); + + $this->assertSame($time, $time->min($later)); + $this->assertTrue($later->min($time)->sameAs($time)); + } + + public function testMinReturnsCurrentInstanceOnEqualTime(): void + { + $time = new Time('2024-01-01 12:00:00'); + $equal = new Time('2024-01-01 12:00:00'); + + $this->assertSame($time, $time->min($equal)); + } + + public function testMinSupportsTimezoneForStringInputs(): void + { + $time = Time::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->min('2024-01-01 13:00:00', 'Europe/Warsaw')->sameAs('2024-01-01 13:00:00', 'Europe/Warsaw')); + } + + public function testMinWithNullUsesNow(): void + { + Time::setTestNow('2024-01-01 12:00:00', 'UTC'); + + $past = Time::parse('2024-01-01 11:59:59', 'UTC'); + $future = Time::parse('2024-01-01 12:00:01', 'UTC'); + + $this->assertSame($past, $past->min()); + $this->assertTrue($future->min()->sameAs(Time::now('UTC'))); + } + + public function testMaxReturnsLaterTime(): void + { + $time = new Time('2024-01-01 12:00:00'); + $earlier = new Time('2024-01-01 11:59:59'); + + $this->assertSame($time, $time->max($earlier)); + $this->assertTrue($earlier->max($time)->sameAs($time)); + } + + public function testMaxReturnsCurrentInstanceOnEqualTime(): void + { + $time = new Time('2024-01-01 12:00:00'); + $equal = new Time('2024-01-01 12:00:00'); + + $this->assertSame($time, $time->max($equal)); + } + + public function testMaxSupportsTimezoneForStringInputs(): void + { + $time = Time::parse('2024-01-01 12:30:00', 'UTC'); + + $this->assertTrue($time->max('2024-01-01 13:00:00', 'Europe/Warsaw')->sameAs($time)); + } + + public function testMaxWithNullUsesNow(): void + { + Time::setTestNow('2024-01-01 12:00:00', 'UTC'); + + $past = Time::parse('2024-01-01 11:59:59', 'UTC'); + $future = Time::parse('2024-01-01 12:00:01', 'UTC'); + + $this->assertTrue($past->max()->sameAs(Time::now('UTC'))); + $this->assertSame($future, $future->max()); + } + public function testIsPast(): void { Time::setTestNow('2025-12-30 12:00:00', 'Asia/Tehran'); diff --git a/tests/system/Images/GDHandlerTest.php b/tests/system/Images/GDHandlerTest.php index d85d1a142ffd..9d92c618c366 100644 --- a/tests/system/Images/GDHandlerTest.php +++ b/tests/system/Images/GDHandlerTest.php @@ -319,10 +319,14 @@ public function testMoreText(): void public function testImageCreation(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! function_exists('imagecreatefromwebp')) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! function_exists('imagecreatefromavif')) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type); @@ -334,10 +338,14 @@ public function testImageCreation(): void public function testImageCopy(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! function_exists('imagecreatefromwebp')) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! function_exists('imagecreatefromavif')) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type); @@ -353,7 +361,7 @@ public function testImageCopy(): void public function testImageCopyWithNoTargetAndMaxQuality(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { $this->handler->withFile($this->origin . 'ci-logo.' . $type); $this->handler->save(null, 100); $this->assertFileExists($this->origin . 'ci-logo.' . $type); @@ -367,10 +375,14 @@ public function testImageCopyWithNoTargetAndMaxQuality(): void public function testImageCompressionGetResource(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! function_exists('imagecreatefromwebp')) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! function_exists('imagecreatefromavif')) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type); @@ -387,10 +399,14 @@ public function testImageCompressionGetResource(): void public function testImageCompressionWithResource(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! function_exists('imagecreatefromwebp')) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! function_exists('imagecreatefromavif')) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type) @@ -423,6 +439,15 @@ public function testImageConvertPngToWebp(): void $this->assertSame(IMAGETYPE_WEBP, exif_imagetype($saved)); } + public function testImageConvertPngToAvif(): void + { + $this->handler->withFile($this->origin . 'rocket.png'); + $this->handler->convert(IMAGETYPE_AVIF); + $saved = $this->start . 'work/rocket.avif'; + $this->handler->save($saved); + $this->assertSame(IMAGETYPE_AVIF, exif_imagetype($saved)); + } + public function testImageReorientLandscape(): void { for ($i = 0; $i <= 8; $i++) { diff --git a/tests/system/Images/ImageMagickHandlerTest.php b/tests/system/Images/ImageMagickHandlerTest.php index 9c322c8c520f..535ad5e39fd7 100644 --- a/tests/system/Images/ImageMagickHandlerTest.php +++ b/tests/system/Images/ImageMagickHandlerTest.php @@ -313,10 +313,14 @@ public function testMoreText(): void public function testImageCreation(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! in_array('WEBP', Imagick::queryFormats(), true)) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! in_array('AVIF', Imagick::queryFormats(), true)) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type); @@ -328,10 +332,14 @@ public function testImageCreation(): void public function testImageCopy(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! in_array('WEBP', Imagick::queryFormats(), true)) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! in_array('AVIF', Imagick::queryFormats(), true)) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type); @@ -347,7 +355,7 @@ public function testImageCopy(): void public function testImageCopyWithNoTargetAndMaxQuality(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { $this->handler->withFile($this->origin . 'ci-logo.' . $type); $this->handler->save(null, 100); $this->assertFileExists($this->origin . 'ci-logo.' . $type); @@ -361,10 +369,14 @@ public function testImageCopyWithNoTargetAndMaxQuality(): void public function testImageCompressionGetResource(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! in_array('WEBP', Imagick::queryFormats(), true)) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! in_array('AVIF', Imagick::queryFormats(), true)) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type); @@ -381,10 +393,14 @@ public function testImageCompressionGetResource(): void public function testImageCompressionWithResource(): void { - foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + foreach (['gif', 'jpeg', 'png', 'webp', 'avif'] as $type) { if ($type === 'webp' && ! in_array('WEBP', Imagick::queryFormats(), true)) { $this->expectException(ImageException::class); - $this->expectExceptionMessage('Your server does not support the GD function required to process this type of image.'); + $this->expectExceptionMessage('Your server does not support the GD function required to process a webp image.'); + } + if ($type === 'avif' && ! in_array('AVIF', Imagick::queryFormats(), true)) { + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Your server does not support the GD function required to process an avif image.'); } $this->handler->withFile($this->origin . 'ci-logo.' . $type) @@ -408,6 +424,22 @@ public function testImageConvert(): void $this->assertSame(IMAGETYPE_PNG, exif_imagetype($this->root . 'ci-logo.png')); } + public function testImageConvertPngToWebp(): void + { + $this->handler->withFile($this->origin . 'ci-logo.png'); + $this->handler->convert(IMAGETYPE_WEBP); + $this->handler->save($this->root . 'ci-logo.webp'); + $this->assertSame(IMAGETYPE_WEBP, exif_imagetype($this->root . 'ci-logo.webp')); + } + + public function testImageConvertPngToAvif(): void + { + $this->handler->withFile($this->origin . 'ci-logo.png'); + $this->handler->convert(IMAGETYPE_AVIF); + $this->handler->save($this->root . 'ci-logo.avif'); + $this->assertSame(IMAGETYPE_AVIF, exif_imagetype($this->root . 'ci-logo.avif')); + } + public function testImageReorientLandscape(): void { for ($i = 0; $i <= 8; $i++) { diff --git a/tests/system/Input/InputDataFactoryTest.php b/tests/system/Input/InputDataFactoryTest.php new file mode 100644 index 000000000000..2736a708b8b3 --- /dev/null +++ b/tests/system/Input/InputDataFactoryTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class InputDataFactoryTest extends CIUnitTestCase +{ + public function testCreateReturnsInputData(): void + { + $factory = new InputDataFactory(); + $input = $factory->create(['page' => '2']); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(2, $input->integer('page')); + } + + public function testCreateReturnsNewInputDataInstances(): void + { + $factory = new InputDataFactory(); + + $this->assertNotSame($factory->create([]), $factory->create([])); + } + + public function testCreateValidatedReturnsValidatedInput(): void + { + $factory = new InputDataFactory(); + $input = $factory->createValidated(['page' => '2']); + + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame(2, $input->integer('page')); + } + + public function testCreateValidatedReturnsNewValidatedInputInstances(): void + { + $factory = new InputDataFactory(); + + $this->assertNotSame($factory->createValidated([]), $factory->createValidated([])); + } +} diff --git a/tests/system/Input/InputDataTest.php b/tests/system/Input/InputDataTest.php new file mode 100644 index 000000000000..9e1122fbbb32 --- /dev/null +++ b/tests/system/Input/InputDataTest.php @@ -0,0 +1,203 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class InputDataTest extends CIUnitTestCase +{ + public function testGetReturnsInputFieldValue(): void + { + $input = new InputData(['title' => 'Hello World']); + + $this->assertSame('Hello World', $input->get('title')); + } + + public function testGetReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame('fallback', $input->get('title', 'fallback')); + } + + public function testHasReturnsTrueForNullInputField(): void + { + $input = new InputData(['note' => null]); + + $this->assertTrue($input->has('note')); + $this->assertNull($input->get('note', 'fallback')); + } + + public function testGetAndHasSupportDotSyntax(): void + { + $input = new InputData([ + 'post' => [ + 'meta' => [ + 'slug' => 'hello-world', + ], + ], + ]); + + $this->assertSame('hello-world', $input->get('post.meta.slug')); + $this->assertTrue($input->has('post.meta.slug')); + } + + public function testStringReturnsInputString(): void + { + $input = new InputData(['title' => 'Hello World']); + + $this->assertSame('Hello World', $input->string('title')); + } + + public function testStringReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame('Untitled', $input->string('title', 'Untitled')); + } + + public function testStringReturnsDefaultForInvalidInputValue(): void + { + $input = new InputData(['title' => 123]); + + $this->assertSame('Untitled', $input->string('title', 'Untitled')); + } + + public function testIntegerReturnsInputInteger(): void + { + $input = new InputData(['page' => '15']); + + $this->assertSame(15, $input->integer('page')); + } + + public function testIntegerReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame(1, $input->integer('page', 1)); + } + + public function testIntegerSupportsDotSyntax(): void + { + $input = new InputData(['filters' => ['page' => '2']]); + + $this->assertSame(2, $input->integer('filters.page')); + } + + public function testIntegerReturnsDefaultForInvalidInputValue(): void + { + $input = new InputData(['page' => '1.5']); + + $this->assertSame(1, $input->integer('page', 1)); + } + + public function testFloatReturnsInputFloat(): void + { + $input = new InputData(['price' => '15.50']); + + $this->assertEqualsWithDelta(15.50, $input->float('price'), PHP_FLOAT_EPSILON); + } + + public function testFloatReturnsInputIntegerAsFloat(): void + { + $input = new InputData(['price' => 15]); + + $this->assertEqualsWithDelta(15.0, $input->float('price'), PHP_FLOAT_EPSILON); + } + + public function testFloatReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertEqualsWithDelta(1.5, $input->float('price', 1.5), PHP_FLOAT_EPSILON); + } + + public function testFloatReturnsDefaultForInvalidInputValue(): void + { + $input = new InputData(['price' => 'free']); + + $this->assertEqualsWithDelta(1.5, $input->float('price', 1.5), PHP_FLOAT_EPSILON); + } + + public function testBooleanReturnsInputBoolean(): void + { + $input = new InputData(['active' => 'true']); + + $this->assertTrue($input->boolean('active')); + } + + public function testBooleanReturnsFalseForInputFalseString(): void + { + $input = new InputData(['active' => 'false']); + + $this->assertFalse($input->boolean('active')); + } + + public function testBooleanReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertFalse($input->boolean('active', false)); + } + + public function testBooleanReturnsDefaultForInvalidInputValue(): void + { + $input = new InputData(['active' => 'sometimes']); + + $this->assertFalse($input->boolean('active', false)); + } + + public function testArrayReturnsInputArray(): void + { + $input = new InputData(['tags' => ['php', 'ci']]); + + $this->assertSame(['php', 'ci'], $input->array('tags')); + } + + public function testArrayReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame(['draft'], $input->array('tags', ['draft'])); + } + + public function testArrayReturnsDefaultForInvalidInputValue(): void + { + $input = new InputData(['tags' => 'php']); + + $this->assertSame(['draft'], $input->array('tags', ['draft'])); + } + + public function testTypedAccessorsReturnNullForNullInputFields(): void + { + $input = new InputData([ + 'title' => null, + 'page' => null, + 'price' => null, + 'active' => null, + 'tags' => null, + ]); + + $this->assertNull($input->string('title', 'Untitled')); + $this->assertNull($input->integer('page', 1)); + $this->assertNull($input->float('price', 1.5)); + $this->assertNull($input->boolean('active', false)); + $this->assertNull($input->array('tags', ['draft'])); + } +} diff --git a/tests/system/Input/ValidatedInputTest.php b/tests/system/Input/ValidatedInputTest.php new file mode 100644 index 000000000000..a7bba1ba8257 --- /dev/null +++ b/tests/system/Input/ValidatedInputTest.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Enum\ColorEnum; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; + +/** + * @internal + */ +#[Group('Others')] +final class ValidatedInputTest extends CIUnitTestCase +{ + public function testValidatedInputExtendsInputData(): void + { + $input = new ValidatedInput(['page' => '15']); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(15, $input->integer('page')); + } + + public function testDateReturnsValidatedTime(): void + { + $input = new ValidatedInput(['published_at' => '2026-05-04']); + + $this->assertInstanceOf(Time::class, $input->date('published_at')); + $this->assertSame('2026-05-04', $input->date('published_at')->toDateString()); + } + + public function testDateSupportsCustomFormat(): void + { + $input = new ValidatedInput(['published_at' => '04/05/2026']); + + $this->assertSame('2026-05-04', $input->date('published_at', 'd/m/Y')->toDateString()); + } + + public function testDateReturnsDefaultForMissingValidatedField(): void + { + $input = new ValidatedInput([]); + $default = Time::parse('2026-05-04'); + + $this->assertSame($default, $input->date('published_at', default: $default)); + } + + public function testDateThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['published_at' => '']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "published_at" value cannot be read as date.'); + + $input->date('published_at'); + } + + public function testStringThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['title' => 123]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "title" value cannot be read as string.'); + + $input->string('title', 'Untitled'); + } + + public function testIntegerThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['page' => '1.5']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "page" value cannot be read as integer.'); + + $input->integer('page', 1); + } + + public function testFloatThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['price' => 'free']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "price" value cannot be read as float.'); + + $input->float('price', 1.5); + } + + public function testBooleanThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['active' => 'sometimes']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "active" value cannot be read as boolean.'); + + $input->boolean('active', false); + } + + public function testArrayThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['tags' => 'php']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "tags" value cannot be read as array.'); + + $input->array('tags', ['draft']); + } + + public function testEnumReturnsValidatedStringBackedEnum(): void + { + $input = new ValidatedInput(['status' => 'active']); + + $this->assertSame(StatusEnum::ACTIVE, $input->enum('status', StatusEnum::class)); + } + + public function testEnumReturnsValidatedIntBackedEnum(): void + { + $input = new ValidatedInput(['role' => '2']); + + $this->assertSame(RoleEnum::ADMIN, $input->enum('role', RoleEnum::class)); + } + + public function testEnumReturnsValidatedUnitEnum(): void + { + $input = new ValidatedInput(['color' => 'GREEN']); + + $this->assertSame(ColorEnum::GREEN, $input->enum('color', ColorEnum::class)); + } + + public function testEnumReturnsDefaultForMissingValidatedField(): void + { + $input = new ValidatedInput([]); + + $this->assertSame(StatusEnum::PENDING, $input->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } + + public function testEnumThrowsForDefaultFromDifferentEnumClass(): void + { + $input = new ValidatedInput([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); + + $input->enum('status', StatusEnum::class, ColorEnum::GREEN); + } + + public function testEnumThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['status' => 'archived']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); + + $input->enum('status', StatusEnum::class); + } + + public function testTypedAccessorsReturnNullForNullValidatedFields(): void + { + $input = new ValidatedInput([ + 'published_at' => null, + 'status' => null, + ]); + $default = Time::parse('2026-05-04'); + + $this->assertNotInstanceOf(Time::class, $input->date('published_at', default: $default)); + $this->assertNotInstanceOf(StatusEnum::class, $input->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } +} diff --git a/tests/system/Language/LanguageTest.php b/tests/system/Language/LanguageTest.php index 6233941e7d75..d6de65be203f 100644 --- a/tests/system/Language/LanguageTest.php +++ b/tests/system/Language/LanguageTest.php @@ -265,11 +265,16 @@ public function testGetLocale(): void public function testPrioritizedLocator(): void { - // this should load the replacement bundle of messages - $message = lang('Core.missingExtension', [], 'en'); - $this->assertSame('The framework needs the following extension(s) installed and loaded: "{0}".', $message); - // and we should have our new message too - $this->assertSame('billions and billions', lang('Core.bazillion', [], 'en')); + $this->assertSame( + 'Invalid file: "{0}"', + lang('Core.invalidFile', [], 'en'), + 'Failed asserting that the system language file is prioritized over the test support language file.', + ); + $this->assertSame( + 'billions and billions', + lang('Core.bazillion', [], 'en'), + 'Failed asserting that the test support language file is used if key is not found in the system language file.', + ); } /** diff --git a/tests/system/Lock/LockTest.php b/tests/system/Lock/LockTest.php new file mode 100644 index 000000000000..05d736e1a3cb --- /dev/null +++ b/tests/system/Lock/LockTest.php @@ -0,0 +1,224 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Lock; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\Exceptions\CacheException; +use CodeIgniter\Cache\Handlers\DummyHandler; +use CodeIgniter\Cache\LockStoreInterface; +use CodeIgniter\Cache\LockStoreProviderInterface; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use CodeIgniter\Lock\Exceptions\LockException; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Cache; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class LockTest extends CIUnitTestCase +{ + private Cache $config; + private LockManager $locks; + + protected function setUp(): void + { + parent::setUp(); + + helper('filesystem'); + + $this->config = new Cache(); + $this->config->file['storePath'] = WRITEPATH . 'cache/LockTest'; + + if (! is_dir($this->config->file['storePath'])) { + mkdir($this->config->file['storePath'], 0777, true); + } + + $this->locks = new LockManager(CacheFactory::getHandler($this->config, 'file')); + } + + protected function tearDown(): void + { + parent::tearDown(); + + Time::setTestNow(); + + if (is_dir($this->config->file['storePath'])) { + delete_files($this->config->file['storePath'], false, true); + rmdir($this->config->file['storePath']); + } + } + + public function testLockCanBeAcquiredAndReleased(): void + { + $lock = $this->locks->create('reports.daily-export', 60); + + $this->assertTrue($lock->acquire()); + $this->assertFileExists($this->lockFile('reports.daily-export')); + $this->assertTrue($lock->isAcquired()); + $this->assertTrue($lock->release()); + $this->assertFalse($lock->isAcquired()); + $this->assertTrue($this->locks->create('reports.daily-export', 60)->acquire()); + } + + public function testCompetingLockCannotBeAcquiredUntilReleased(): void + { + $first = $this->locks->create('reports.daily-export', 60); + $second = $this->locks->create('reports.daily-export', 60); + + $this->assertTrue($first->acquire()); + $this->assertFalse($second->acquire()); + + $this->assertTrue($first->release()); + $this->assertTrue($second->acquire()); + } + + public function testSameLockCannotBeAcquiredTwice(): void + { + $lock = $this->locks->create('reports.daily-export', 60); + + $this->assertTrue($lock->acquire()); + $this->assertFalse($lock->acquire()); + } + + public function testExpiredLockCanBeAcquiredByNewOwner(): void + { + Time::setTestNow('2026-01-01 12:00:00'); + + $first = $this->locks->create('imports.customer-feed', 10); + + $this->assertTrue($first->acquire()); + + Time::setTestNow('2026-01-01 12:00:11'); + + $second = $this->locks->create('imports.customer-feed', 10); + + $this->assertTrue($second->acquire()); + $this->assertFalse($first->isAcquired()); + } + + public function testOnlyOwnerCanReleaseLock(): void + { + $first = $this->locks->create('payments.settlement', 60); + $second = $this->locks->create('payments.settlement', 60); + + $this->assertTrue($first->acquire()); + $this->assertFalse($second->release()); + $this->assertTrue($first->isAcquired()); + } + + public function testForceReleaseIgnoresOwner(): void + { + $first = $this->locks->create('payments.settlement', 60); + $second = $this->locks->create('payments.settlement', 60); + + $this->assertTrue($first->acquire()); + $this->assertTrue($second->forceRelease()); + $this->assertTrue($second->acquire()); + } + + public function testRestoreCanReleaseOwnedLock(): void + { + $lock = $this->locks->create('jobs.unique', 60); + + $this->assertTrue($lock->acquire()); + + $restored = $this->locks->restore('jobs.unique', $lock->owner(), 60); + + $this->assertTrue($restored->isAcquired()); + $this->assertTrue($restored->release()); + $this->assertFalse($lock->isAcquired()); + } + + public function testRefreshRequiresOwner(): void + { + $first = $this->locks->create('cache.rebuild', 60); + $second = $this->locks->create('cache.rebuild', 60); + + $this->assertTrue($first->acquire()); + $this->assertTrue($first->refresh(120)); + $this->assertFalse($second->refresh(120)); + } + + public function testRunReleasesLockAfterCallback(): void + { + $lock = $this->locks->create('notifications.send', 60); + + $this->assertSame('sent', $lock->run(static fn (): string => 'sent')); + $this->assertTrue($this->locks->create('notifications.send', 60)->acquire()); + } + + public function testRunReturnsFalseWhenLockCannotBeAcquired(): void + { + $first = $this->locks->create('notifications.send', 60); + $second = $this->locks->create('notifications.send', 60); + + $this->assertTrue($first->acquire()); + $this->assertFalse($second->run(static fn (): string => 'sent')); + } + + public function testLogicalNamesCanContainReservedCacheCharacters(): void + { + $lock = $this->locks->create('tenant:1/payments/{settlement}', 60); + + $this->assertTrue($lock->acquire()); + } + + public function testEmptyLockNameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->locks->create(''); + } + + public function testNonPositiveTtlIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be a positive integer.'); + + $this->locks->create('reports.daily-export', 0); + } + + public function testUnsupportedCacheHandlerThrows(): void + { + $this->expectException(LockException::class); + $this->expectExceptionMessage('does not support locks'); + + // @phpstan-ignore argument.type + new LockManager(CacheFactory::getHandler($this->config, 'dummy')); + } + + public function testUnsupportedLockStoreProviderThrows(): void + { + $cache = new class () extends DummyHandler implements LockStoreProviderInterface { + public function lockStore(): LockStoreInterface + { + throw CacheException::forUnsupportedLockStore(); + } + }; + + $this->expectException(LockException::class); + $this->expectExceptionMessage('does not support locks'); + + new LockManager($cache); + } + + private function lockFile(string $name): string + { + return rtrim($this->config->file['storePath'], '\\/') . '/lock_' . hash('xxh128', $name); + } +} diff --git a/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php b/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php index 73461c3c96ed..000bf2ea9c78 100644 --- a/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php +++ b/tests/system/Log/Handlers/ChromeLoggerHandlerTest.php @@ -16,7 +16,6 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockLogger as LoggerConfig; use CodeIgniter\Test\Mock\MockResponse; -use Config\App; use Config\Services; use PHPUnit\Framework\Attributes\Group; use stdClass; @@ -75,9 +74,29 @@ public function testSetDateFormat(): void $this->assertSame('F j, Y', $this->getPrivateProperty($logger, 'dateFormat')); } + public function testHandleIncludesContextInRow(): void + { + Services::injectMock('response', new MockResponse()); + $response = service('response'); + + $config = new LoggerConfig(); + $config->handlers['CodeIgniter\Log\Handlers\TestHandler']['handles'] = ['debug']; + + $logger = new ChromeLoggerHandler($config->handlers['CodeIgniter\Log\Handlers\TestHandler']); + $logger->handle('debug', 'Test message', [HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']]); + + $this->assertTrue($response->hasHeader('X-ChromeLogger-Data')); + + $decoded = json_decode(base64_decode($response->getHeaderLine('X-ChromeLogger-Data'), true), true); + $logArgs = $decoded['rows'][0][0]; + + $this->assertSame('Test message', $logArgs[0]); + $this->assertSame([HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']], $logArgs[1]); + } + public function testChromeLoggerHeaderSent(): void { - Services::injectMock('response', new MockResponse(new App())); + Services::injectMock('response', new MockResponse()); $response = service('response'); $config = new LoggerConfig(); diff --git a/tests/system/Log/Handlers/ErrorlogHandlerTest.php b/tests/system/Log/Handlers/ErrorlogHandlerTest.php index 2383138d7306..7aa89ab2a933 100644 --- a/tests/system/Log/Handlers/ErrorlogHandlerTest.php +++ b/tests/system/Log/Handlers/ErrorlogHandlerTest.php @@ -38,6 +38,15 @@ public function testErrorLoggingWithErrorLog(): void $this->assertTrue($logger->handle('error', 'Test message.')); } + public function testErrorLoggingAppendsContextAsJson(): void + { + $logger = $this->getMockedHandler(['handles' => ['critical', 'error']]); + $logger->method('errorLog')->willReturn(true); + $logger->expects($this->once())->method('errorLog') + ->with("ERROR --> Test message. {\"_ci_context\":{\"foo\":\"bar\"}}\n", 0); + $this->assertTrue($logger->handle('error', 'Test message.', [HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']])); + } + /** * @param array{handles?: list, messageType?: int} $config * diff --git a/tests/system/Log/Handlers/FileHandlerTest.php b/tests/system/Log/Handlers/FileHandlerTest.php index c810c96f9750..ab16dee5615f 100644 --- a/tests/system/Log/Handlers/FileHandlerTest.php +++ b/tests/system/Log/Handlers/FileHandlerTest.php @@ -79,6 +79,24 @@ public function testHandleCreateFile(): void $this->assertStringContainsString($expectedResult, (string) $line); } + public function testHandleAppendsContextAsJson(): void + { + $config = new LoggerConfig(); + $config->handlers[TestHandler::class]['path'] = $this->start; + $logger = new MockFileLogger($config->handlers[TestHandler::class]); + + $logger->setDateFormat('Y-m-d'); + $expected = 'log-' . date('Y-m-d') . '.log'; + vfsStream::newFile($expected)->at(vfsStream::setup('root'))->withContent(''); + $logger->handle('debug', 'Test message', [HandlerInterface::GLOBAL_CONTEXT_KEY => ['foo' => 'bar']]); + + $fp = fopen($config->handlers[TestHandler::class]['path'] . $expected, 'rb'); + $line = fgets($fp); + fclose($fp); + + $this->assertStringContainsString('Test message {"_ci_context":{"foo":"bar"}}', (string) $line); + } + public function testHandleDateTimeCorrectly(): void { $config = new LoggerConfig(); diff --git a/tests/system/Log/LoggerTest.php b/tests/system/Log/LoggerTest.php index 80d42d6c83b3..10b6a7293446 100644 --- a/tests/system/Log/LoggerTest.php +++ b/tests/system/Log/LoggerTest.php @@ -36,6 +36,8 @@ protected function tearDown(): void // Reset the current time. Time::setTestNow(); + + service('context')->clearAll(); // Clear any context data that may have been set during tests. } public function testThrowsExceptionWithBadHandlerSettings(): void @@ -438,4 +440,239 @@ public function testDetermineFileNoStackTrace(): void $this->assertSame($expected, $logger->determineFile()); } + + public function testLogsGlobalContext(): void + { + $config = new LoggerConfig(); + $config->logGlobalContext = true; + + $logger = new Logger($config); + + Time::setTestNow('2026-02-18 12:00:00'); + + service('context')->set('foo', 'bar'); + + $expectedMessage = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message'; + + $logger->log('debug', 'Test message'); + + $logs = TestHandler::getLogs(); + $contexts = TestHandler::getContexts(); + + $this->assertCount(1, $logs); + $this->assertSame($expectedMessage, $logs[0]); + $this->assertSame(['_ci_context' => ['foo' => 'bar']], $contexts[0]); + } + + public function testDoesNotLogGlobalContext(): void + { + $config = new LoggerConfig(); + $config->logGlobalContext = false; + + $logger = new Logger($config); + + Time::setTestNow('2026-02-18 12:00:00'); + + service('context')->set('foo', 'bar'); + + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message'; + + $logger->log('debug', 'Test message'); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testDoesNotLogHiddenGlobalContext(): void + { + $config = new LoggerConfig(); + $config->logGlobalContext = true; + + $logger = new Logger($config); + + Time::setTestNow('2026-02-18 12:00:00'); + + service('context')->setHidden('secret', 'hidden value'); + + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message'; + + $logger->log('debug', 'Test message'); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testContextNotPassedToHandlersByDefault(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + $logger->log('debug', 'Test message', ['foo' => 'bar', 'baz' => 'qux']); + + $contexts = TestHandler::getContexts(); + + $this->assertSame([[]], $contexts); + } + + public function testLogContextPassesNonInterpolatedKeysToHandlers(): void + { + $config = new LoggerConfig(); + $config->logContext = true; + + $logger = new Logger($config); + + $logger->log('debug', 'Hello {name}', ['name' => 'World', 'user_id' => 42]); + + $contexts = TestHandler::getContexts(); + + $this->assertArrayNotHasKey('name', $contexts[0]); + $this->assertSame(42, $contexts[0]['user_id']); + } + + public function testLogContextStripsInterpolatedKeysByDefault(): void + { + $config = new LoggerConfig(); + $config->logContext = true; + + $logger = new Logger($config); + + $logger->log('debug', 'Hello {name}', ['name' => 'World']); + + $contexts = TestHandler::getContexts(); + + $this->assertSame([[]], $contexts); + } + + public function testLogContextKeepsInterpolatedKeysWhenEnabled(): void + { + $config = new LoggerConfig(); + $config->logContext = true; + $config->logContextUsedKeys = true; + + $logger = new Logger($config); + + $logger->log('debug', 'Hello {name}', ['name' => 'World']); + + $contexts = TestHandler::getContexts(); + + $this->assertArrayHasKey('name', $contexts[0]); + $this->assertSame('World', $contexts[0]['name']); + } + + public function testLogContextNormalizesThrowable(): void + { + $config = new LoggerConfig(); + $config->logContext = true; + + $logger = new Logger($config); + + try { + throw new RuntimeException('Something went wrong', 42); + } catch (RuntimeException $e) { + $logger->log('error', 'An error occurred', ['exception' => $e]); + } + + $contexts = TestHandler::getContexts(); + + $this->assertArrayHasKey('exception', $contexts[0]); + + $normalized = $contexts[0]['exception']; + + $this->assertSame(RuntimeException::class, $normalized['class']); + $this->assertSame('Something went wrong', $normalized['message']); + $this->assertSame(42, $normalized['code']); + $this->assertArrayHasKey('file', $normalized); + $this->assertArrayHasKey('line', $normalized); + $this->assertArrayNotHasKey('trace', $normalized); + } + + public function testLogContextDoesNotNormalizeThrowableUnderArbitraryKey(): void + { + $config = new LoggerConfig(); + $config->logContext = true; + + $logger = new Logger($config); + + try { + throw new RuntimeException('Something went wrong'); + } catch (RuntimeException $e) { + $logger->log('error', 'An error occurred', ['error' => $e]); + } + + $contexts = TestHandler::getContexts(); + + // Per PSR-3, only the 'exception' key is normalized; other keys are left as-is. + $this->assertInstanceOf(RuntimeException::class, $contexts[0]['error']); + } + + public function testLogContextNormalizesThrowableWithTrace(): void + { + $config = new LoggerConfig(); + $config->logContext = true; + $config->logContextTrace = true; + + $logger = new Logger($config); + + try { + throw new RuntimeException('Something went wrong'); + } catch (RuntimeException $e) { + $logger->log('error', 'An error occurred', ['exception' => $e]); + } + + $contexts = TestHandler::getContexts(); + + $this->assertArrayHasKey('exception', $contexts[0]); + $this->assertArrayHasKey('trace', $contexts[0]['exception']); + $this->assertIsString($contexts[0]['exception']['trace']); + } + + public function testLogContextNormalizesInterpolatedThrowableWhenUsedKeysEnabled(): void + { + $config = new LoggerConfig(); + $config->logContext = true; + $config->logContextUsedKeys = true; + + $logger = new Logger($config); + + try { + throw new RuntimeException('Something went wrong'); + } catch (RuntimeException $e) { + $logger->log('error', '[ERROR] {exception}', ['exception' => $e]); + } + + $contexts = TestHandler::getContexts(); + + $this->assertArrayHasKey('exception', $contexts[0]); + + $normalized = $contexts[0]['exception']; + + $this->assertIsArray($normalized); + $this->assertSame(RuntimeException::class, $normalized['class']); + $this->assertSame('Something went wrong', $normalized['message']); + } + + public function testLogContextDisabledStillAllowsGlobalContext(): void + { + $config = new LoggerConfig(); + $config->logContext = false; + $config->logGlobalContext = true; + + $logger = new Logger($config); + + Time::setTestNow('2026-02-18 12:00:00'); + + service('context')->set('request_id', 'abc123'); + + $logger->log('debug', 'Test message', ['extra' => 'data']); + + $contexts = TestHandler::getContexts(); + + $this->assertArrayNotHasKey('extra', $contexts[0]); + $this->assertArrayHasKey('_ci_context', $contexts[0]); + $this->assertSame(['request_id' => 'abc123'], $contexts[0]['_ci_context']); + } } diff --git a/tests/system/Models/ExistsModelTest.php b/tests/system/Models/ExistsModelTest.php new file mode 100644 index 000000000000..1dc617e40bc3 --- /dev/null +++ b/tests/system/Models/ExistsModelTest.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\Models; + +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExistsModelTest extends LiveModelTestCase +{ + public function testExistsRespectsSoftDeletes(): void + { + $this->createModel(UserModel::class); + $this->model->delete(1); + + $this->assertFalse($this->model->where('id', 1)->exists()); + $this->assertTrue($this->model->withDeleted()->where('id', 1)->exists()); + } + + public function testDoesntExistRespectsSoftDeletes(): void + { + $this->createModel(UserModel::class); + $this->model->delete(1); + + $this->assertTrue($this->model->where('id', 1)->doesntExist()); + $this->assertFalse($this->model->withDeleted()->where('id', 1)->doesntExist()); + } +} diff --git a/tests/system/Models/ExplainModelTest.php b/tests/system/Models/ExplainModelTest.php new file mode 100644 index 000000000000..e209722c43cb --- /dev/null +++ b/tests/system/Models/ExplainModelTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExplainModelTest extends LiveModelTestCase +{ + public function testExplainRespectsSoftDeletesInTestMode(): void + { + if (in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' does not support explain().'); + } + + $this->createModel(UserModel::class); + + $sql = $this->model->where('id', 1)->explain(test: true); + + $expectedPrefix = $this->db->DBDriver === 'SQLite3' + ? 'EXPLAIN QUERY PLAN SELECT' + : 'EXPLAIN SELECT'; + + $this->assertStringStartsWith($expectedPrefix, str_replace("\n", ' ', (string) $sql)); + $this->assertStringContainsString('deleted_at', (string) $sql); + } + + public function testExplainWithDeletedOmitsSoftDeleteConstraintInTestMode(): void + { + if (in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' does not support explain().'); + } + + $this->createModel(UserModel::class); + + $sql = $this->model->withDeleted()->where('id', 1)->explain(test: true); + + $this->assertStringNotContainsString('deleted_at', (string) $sql); + } +} diff --git a/tests/system/Models/FirstOrInsertModelTest.php b/tests/system/Models/FirstOrInsertModelTest.php new file mode 100644 index 000000000000..61a68f2aad22 --- /dev/null +++ b/tests/system/Models/FirstOrInsertModelTest.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Exceptions\InvalidArgumentException; +use PHPUnit\Framework\Attributes\Group; +use ReflectionProperty; +use stdClass; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class FirstOrInsertModelTest extends LiveModelTestCase +{ + protected function tearDown(): void + { + $this->enableDBDebug(); + parent::tearDown(); + } + + public function testReturnsExistingRecord(): void + { + $this->createModel(UserModel::class); + + $row = $this->model->firstOrInsert(['email' => 'derek@world.com']); + + $this->assertIsObject($row); + $this->assertSame('Derek Jones', $row->name); + $this->assertSame('derek@world.com', $row->email); + $this->assertSame('US', $row->country); + } + + public function testDoesNotInsertWhenRecordExists(): void + { + $this->createModel(UserModel::class); + + $this->model->firstOrInsert(['email' => 'derek@world.com']); + + // Seeder inserts 4 users; calling firstOrInsert on an existing + // record must not add a fifth one. + $this->seeNumRecords(4, 'user', ['deleted_at' => null]); + } + + public function testValuesAreIgnoredWhenRecordExists(): void + { + $this->createModel(UserModel::class); + + // The $values array must not be used to modify the found record. + $row = $this->model->firstOrInsert( + ['email' => 'derek@world.com'], + ['name' => 'Should Not Change', 'country' => 'XX'], + ); + + $this->assertIsObject($row); + $this->assertSame('Derek Jones', $row->name); + $this->assertSame('US', $row->country); + } + + public function testInsertsNewRecordWhenNotFound(): void + { + $this->createModel(UserModel::class); + + $row = $this->model->firstOrInsert([ + 'name' => 'New User', + 'email' => 'new@example.com', + 'country' => 'US', + ]); + + $this->assertIsObject($row); + $this->assertSame('new@example.com', $row->email); + $this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]); + } + + public function testMergesValuesOnInsert(): void + { + $this->createModel(UserModel::class); + + $row = $this->model->firstOrInsert( + ['email' => 'new@example.com'], + ['name' => 'New User', 'country' => 'CA'], + ); + + $this->assertIsObject($row); + $this->assertSame('New User', $row->name); + $this->assertSame('CA', $row->country); + $this->seeInDatabase('user', [ + 'email' => 'new@example.com', + 'name' => 'New User', + 'country' => 'CA', + 'deleted_at' => null, + ]); + } + + public function testAcceptsObjectForValues(): void + { + $this->createModel(UserModel::class); + + $values = new stdClass(); + $values->name = 'Object User'; + $values->country = 'DE'; + + $row = $this->model->firstOrInsert( + ['email' => 'object@example.com'], + $values, + ); + + $this->assertIsObject($row); + $this->assertSame('Object User', $row->name); + $this->assertSame('DE', $row->country); + $this->seeInDatabase('user', ['email' => 'object@example.com', 'deleted_at' => null]); + } + + public function testAcceptsObjectForAttributes(): void + { + $this->createModel(UserModel::class); + + $attributes = new stdClass(); + $attributes->email = 'derek@world.com'; + + $row = $this->model->firstOrInsert($attributes); + + $this->assertIsObject($row); + $this->assertSame('Derek Jones', $row->name); + $this->seeNumRecords(4, 'user', ['deleted_at' => null]); + } + + public function testAcceptsObjectForAttributesAndInsertsWhenNotFound(): void + { + $this->createModel(UserModel::class); + + $attributes = new stdClass(); + $attributes->email = 'new@example.com'; + $attributes->name = 'New User'; + $attributes->country = 'US'; + + $row = $this->model->firstOrInsert($attributes); + + $this->assertIsObject($row); + $this->assertSame('new@example.com', $row->email); + $this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]); + } + + public function testThrowsOnEmptyAttributes(): void + { + $this->createModel(UserModel::class); + + $this->expectException(InvalidArgumentException::class); + $this->model->firstOrInsert([]); + } + + public function testHandlesRaceConditionWithDebugEnabled(): void + { + // Subclass that simulates a concurrent insert winning the race: + // doInsert() first persists the row (the "other process"), then + // throws UniqueConstraintViolationException as if our own attempt + // also tried to insert the same row. + $model = new class ($this->db) extends UserModel { + protected function doInsert(array $row): bool + { + parent::doInsert($row); + + throw new UniqueConstraintViolationException('Duplicate entry'); + } + }; + + $row = $model->firstOrInsert( + ['email' => 'race@example.com'], + ['name' => 'Race User', 'country' => 'US'], + ); + + $this->assertIsObject($row); + $this->assertSame('race@example.com', $row->email); + // The "other process" inserted exactly one record. + $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); + } + + public function testHandlesRaceConditionWithDebugDisabled(): void + { + $this->disableDBDebug(); + + // Subclass that simulates a concurrent insert: the "other process" + // inserts via a direct DB call, then our own attempt fails with a + // unique violation which is stored in lastException (DBDebug=false). + $model = new class ($this->db) extends UserModel { + protected function doInsert(array $row): bool + { + // Direct insert – bypasses the model so it won't interfere + // with the model's own builder state. + $this->db->table($this->table)->insert([ + 'name' => $row['name'], + 'email' => $row['email'], + 'country' => $row['country'], + ]); + + // The real insert now fails; the driver stores + // UniqueConstraintViolationException in lastException. + return parent::doInsert($row); + } + }; + + $row = $model->firstOrInsert( + ['email' => 'race@example.com'], + ['name' => 'Race User', 'country' => 'US'], + ); + + $this->assertIsObject($row); + $this->assertSame('race@example.com', $row->email); + $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); + } + + public function testReturnsFalseOnNonUniqueErrorWithDebugDisabled(): void + { + $this->disableDBDebug(); + + // Subclass that simulates a non-unique database error by placing + // a plain DatabaseException (not UniqueConstraintViolationException) + // into lastException and returning false. + $model = new class ($this->db) extends UserModel { + protected function doInsert(array $row): bool + { + $prop = new ReflectionProperty($this->db, 'lastException'); + $prop->setValue($this->db, new DatabaseException('Connection error')); + + return false; + } + }; + + $result = $model->firstOrInsert( + ['email' => 'error@example.com'], + ['name' => 'Error User', 'country' => 'US'], + ); + + $this->assertFalse($result); + $this->dontSeeInDatabase('user', ['email' => 'error@example.com']); + } + + public function testReturnsFalseOnValidationFailure(): void + { + // Subclass with strict validation rules that the test data fails. + $model = new class ($this->db) extends UserModel { + protected $validationRules = [ + 'email' => 'required|valid_email', + 'name' => 'required|min_length[50]', + ]; + }; + + $result = $model->firstOrInsert( + ['email' => 'not-a-valid-email'], + ['name' => 'Too Short'], + ); + + $this->assertFalse($result); + $this->dontSeeInDatabase('user', ['email' => 'not-a-valid-email']); + $this->assertNotEmpty($model->errors()); + } +} diff --git a/tests/system/Models/MiscellaneousModelTest.php b/tests/system/Models/MiscellaneousModelTest.php index ae6d98621819..e8cd505e5ae0 100644 --- a/tests/system/Models/MiscellaneousModelTest.php +++ b/tests/system/Models/MiscellaneousModelTest.php @@ -44,7 +44,7 @@ public function testChunk(): void public function testChunkThrowsOnZeroSize(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('chunk() requires a positive integer for the $size argument.'); + $this->expectExceptionMessage('$size must be a positive integer.'); $this->createModel(UserModel::class)->chunk(0, static function ($row): void {}); } @@ -52,7 +52,7 @@ public function testChunkThrowsOnZeroSize(): void public function testChunkThrowsOnNegativeSize(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('chunk() requires a positive integer for the $size argument.'); + $this->expectExceptionMessage('$size must be a positive integer.'); $this->createModel(UserModel::class)->chunk(-1, static function ($row): void {}); } @@ -97,6 +97,76 @@ public function testChunkEmptyTable(): void $this->assertSame(0, $rowCount); } + public function testChunkRows(): void + { + $chunkCount = 0; + $numRowsInChunk = []; + + $this->createModel(UserModel::class)->chunkRows(2, static function ($rows) use (&$chunkCount, &$numRowsInChunk): void { + $chunkCount++; + $numRowsInChunk[] = count($rows); + }); + + $this->assertSame(2, $chunkCount); + $this->assertSame([2, 2], $numRowsInChunk); + } + + public function testChunkRowsThrowsOnZeroSize(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$size must be a positive integer.'); + + $this->createModel(UserModel::class)->chunkRows(0, static function ($row): void {}); + } + + public function testChunkRowsThrowsOnNegativeSize(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$size must be a positive integer.'); + + $this->createModel(UserModel::class)->chunkRows(-1, static function ($row): void {}); + } + + public function testChunkRowsEarlyExit(): void + { + $rowCount = 0; + + $this->createModel(UserModel::class)->chunkRows(2, static function ($rows) use (&$rowCount): bool { + $rowCount++; + + return false; + }); + + $this->assertSame(1, $rowCount); + } + + public function testChunkRowsDoesNotRunExtraQuery(): void + { + $queryCount = 0; + $listener = static function () use (&$queryCount): void { + $queryCount++; + }; + + Events::on('DBQuery', $listener); + $this->createModel(UserModel::class)->chunkRows(4, static function ($rows): void {}); + Events::removeListener('DBQuery', $listener); + + $this->assertSame(2, $queryCount); + } + + public function testChunkRowsEmptyTable(): void + { + $this->db->table('user')->truncate(); + + $rowCount = 0; + + $this->createModel(UserModel::class)->chunkRows(2, static function ($row) use (&$rowCount): void { + $rowCount++; + }); + + $this->assertSame(0, $rowCount); + } + public function testCanCreateAndSaveEntityClasses(): void { $model = $this->createModel(EntityModel::class); diff --git a/tests/system/Models/ThrowOnDisallowedFieldsModelTest.php b/tests/system/Models/ThrowOnDisallowedFieldsModelTest.php new file mode 100644 index 000000000000..4916b8a03a81 --- /dev/null +++ b/tests/system/Models/ThrowOnDisallowedFieldsModelTest.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use CodeIgniter\Database\Exceptions\DataException; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Models\UserModel; +use Tests\Support\Models\ValidModel; +use Tests\Support\Models\WithoutAutoIncrementModel; +use Throwable; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ThrowOnDisallowedFieldsModelTest extends LiveModelTestCase +{ + public function testDefaultFieldProtectionStillDiscardsDisallowedFields(): void + { + $this->createModel(UserModel::class)->insert([ + 'name' => 'Disallowed Default', + 'email' => 'disallowed-default@example.com', + 'country' => 'US', + 'timezone' => 'UTC', + ]); + + $this->seeInDatabase('user', [ + 'email' => 'disallowed-default@example.com', + ]); + } + + public function testThrowOnDisallowedFieldsThrowsOnInsertDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone'); + + $this->createModel(UserModel::class)->throwOnDisallowedFields()->insert([ + 'name' => 'Disallowed Insert', + 'email' => 'disallowed-insert@example.com', + 'country' => 'US', + 'timezone' => 'UTC', + ]); + } + + public function testThrowOnDisallowedFieldsThrowsOnInsertBatchDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone'); + + $this->createModel(UserModel::class)->throwOnDisallowedFields()->insertBatch([ + [ + 'name' => 'Disallowed Batch', + 'email' => 'disallowed-batch@example.com', + 'country' => 'US', + 'timezone' => 'UTC', + ], + ]); + } + + public function testInsertBatchRestoresCleanValidationRulesWhenDisallowedFieldsThrow(): void + { + $model = $this->createModel(UserModel::class)->throwOnDisallowedFields(); + $exception = null; + + try { + $model->insertBatch([ + [ + 'name' => 'Disallowed Batch Restore', + 'email' => 'disallowed-batch-restore@example.com', + 'country' => 'US', + 'timezone' => 'UTC', + ], + ]); + } catch (Throwable $exception) { + } + + $this->assertInstanceOf(DataException::class, $exception); + $this->assertTrue($this->getPrivateProperty($model, 'cleanValidationRules')); + } + + public function testThrowOnDisallowedFieldsThrowsOnUpdateDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone'); + + $this->createModel(UserModel::class)->throwOnDisallowedFields()->update(1, [ + 'name' => 'Disallowed Update', + 'timezone' => 'UTC', + ]); + } + + public function testThrowOnDisallowedFieldsThrowsOnSaveDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone'); + + $this->createModel(UserModel::class)->throwOnDisallowedFields()->save([ + 'name' => 'Disallowed Save', + 'email' => 'disallowed-save@example.com', + 'country' => 'US', + 'timezone' => 'UTC', + ]); + } + + public function testThrowOnDisallowedFieldsAllowsPrimaryKeyDuringUpdate(): void + { + $result = $this->createModel(UserModel::class)->throwOnDisallowedFields()->update(1, [ + 'id' => 1, + 'name' => 'Disallowed Primary Key', + ]); + + $this->assertTrue($result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Disallowed Primary Key', + ]); + } + + public function testThrowOnDisallowedFieldsAllowsPrimaryKeyDuringConditionalUpdate(): void + { + $result = $this->createModel(UserModel::class)->throwOnDisallowedFields() + ->where('id', 1) + ->update(null, [ + 'id' => 1, + 'name' => 'Disallowed Conditional Primary Key', + ]); + + $this->assertTrue($result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Disallowed Conditional Primary Key', + ]); + } + + public function testThrowOnDisallowedFieldsAllowsBatchIndexDuringUpdateBatch(): void + { + $result = $this->createModel(UserModel::class)->throwOnDisallowedFields()->updateBatch([ + [ + 'id' => 1, + 'name' => 'Disallowed Batch One', + ], + [ + 'id' => 2, + 'name' => 'Disallowed Batch Two', + ], + ], 'id'); + + $this->assertSame(2, $result); + $this->seeInDatabase('user', [ + 'id' => 1, + 'name' => 'Disallowed Batch One', + ]); + $this->seeInDatabase('user', [ + 'id' => 2, + 'name' => 'Disallowed Batch Two', + ]); + } + + public function testThrowOnDisallowedFieldsThrowsOnUpdateBatchDisallowedFields(): void + { + $this->expectException(DataException::class); + $this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone'); + + $this->createModel(UserModel::class)->throwOnDisallowedFields()->updateBatch([ + [ + 'id' => 1, + 'name' => 'Disallowed Batch', + 'timezone' => 'UTC', + ], + ], 'id'); + } + + public function testThrowOnDisallowedFieldsAllowsNonAutoIncrementPrimaryKeyDuringInsert(): void + { + $result = $this->createModel(WithoutAutoIncrementModel::class)->throwOnDisallowedFields()->insert([ + 'key' => 'disallowed-key', + 'value' => 'disallowed value', + ]); + + $this->assertSame('disallowed-key', $result); + $this->seeInDatabase('without_auto_increment', [ + 'key' => 'disallowed-key', + 'value' => 'disallowed value', + ]); + } + + public function testProtectFalseBypassesThrowOnDisallowedFields(): void + { + $result = $this->createModel(UserModel::class)->throwOnDisallowedFields()->protect(false)->update(1, [ + 'name' => 'Disallowed Disabled', + 'created_at' => '2026-01-01 12:00:00', + ]); + + $this->assertTrue($result); + } + + public function testValidationRunsBeforeThrowOnDisallowedFields(): void + { + $model = $this->createModel(ValidModel::class)->throwOnDisallowedFields(); + + $this->assertFalse($model->insert([ + 'description' => 'Missing required name', + 'extra' => 'discarded after validation', + ])); + $this->assertArrayHasKey('name', $model->errors()); + } +} diff --git a/tests/system/RESTful/ResourceControllerTest.php b/tests/system/RESTful/ResourceControllerTest.php index c51e4ae39174..21ad97975867 100644 --- a/tests/system/RESTful/ResourceControllerTest.php +++ b/tests/system/RESTful/ResourceControllerTest.php @@ -323,7 +323,7 @@ public function testJSONFormatOutput(): void $agent = new UserAgent(); $request = new IncomingRequest($config, $uri, '', $agent); - $response = new Response($config); + $response = new Response(); $logger = new NullLogger(); $resource->initController($request, $response, $logger); @@ -351,7 +351,7 @@ public function testXMLFormatOutput(): void $agent = new UserAgent(); $request = new IncomingRequest($config, $uri, '', $agent); - $response = new Response($config); + $response = new Response(); $logger = new NullLogger(); $resource->initController($request, $response, $logger); diff --git a/tests/system/Router/AutoRouterImprovedTest.php b/tests/system/Router/AutoRouterImprovedTest.php index 8a4132a8f297..1f3a4c659777 100644 --- a/tests/system/Router/AutoRouterImprovedTest.php +++ b/tests/system/Router/AutoRouterImprovedTest.php @@ -159,6 +159,55 @@ public function testUriParamCountIsGreaterThanMethodParams(): void $router->getRoute('mycontroller/somemethod/a/b', Method::GET); } + public function testFormRequestParamDoesNotCountAsUriSegment(): void + { + $router = $this->createNewAutoRouter(); + + // FormRequest does not consume a URI segment, so this should route fine + // with zero URI params after the method name. + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller/formmethod', Method::GET); + + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getFormmethod', $method); + $this->assertSame([], $params); + } + + public function testUriParamCountExceedsNonFormRequestParams(): void + { + $this->expectException(PageNotFoundException::class); + + $router = $this->createNewAutoRouter(); + + // Only $id absorbs a URI segment; the FormRequest does not. + // Passing two segments after the method name must be rejected. + $router->getRoute('mycontroller/formmethodwithparam/42/extra', Method::GET); + } + + public function testVariadicParamAlongsideFormRequestAcceptsMultipleUriSegments(): void + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller/formmethod-variadic/php/ci4/tags', Method::GET); + + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getFormmethodVariadic', $method); + $this->assertSame(['php', 'ci4', 'tags'], $params); + } + + public function testVariadicParamAlongsideFormRequestAcceptsZeroUriSegments(): void + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller/formmethod-variadic', Method::GET); + + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getFormmethodVariadic', $method); + $this->assertSame([], $params); + } + public function testAutoRouteFindsControllerWithFile(): void { $router = $this->createNewAutoRouter(); diff --git a/tests/system/Router/Controllers/Mycontroller.php b/tests/system/Router/Controllers/Mycontroller.php index 75ad1eae026e..cb60d9a96f17 100644 --- a/tests/system/Router/Controllers/Mycontroller.php +++ b/tests/system/Router/Controllers/Mycontroller.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Router\Controllers; use CodeIgniter\Controller; +use CodeIgniter\Router\Controllers\Requests\MyFormRequest; class Mycontroller extends Controller { @@ -24,4 +25,16 @@ public function getIndex(): void public function getSomemethod($first = ''): void { } + + public function getFormmethod(MyFormRequest $request): void + { + } + + public function getFormmethodWithParam(string $id, MyFormRequest $request): void + { + } + + public function getFormmethodVariadic(MyFormRequest $request, string ...$tags): void + { + } } diff --git a/tests/system/Router/Controllers/Requests/MyFormRequest.php b/tests/system/Router/Controllers/Requests/MyFormRequest.php new file mode 100644 index 000000000000..c2e4805d7e27 --- /dev/null +++ b/tests/system/Router/Controllers/Requests/MyFormRequest.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers\Requests; + +use CodeIgniter\HTTP\FormRequest; + +class MyFormRequest extends FormRequest +{ + public function rules(): array + { + return []; + } +} diff --git a/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php index 2b257c621c6d..8edab86902dd 100644 --- a/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php @@ -15,7 +15,6 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; -use CodeIgniter\Cookie\Cookie; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; @@ -32,66 +31,59 @@ #[Group('Others')] final class SecurityCSRFCookieRandomizeTokenTest extends CIUnitTestCase { - /** - * @var string CSRF protection hash - */ - private string $hash = '8b9218a55906f9dcc1dc263dce7f005a'; - - /** - * @var string CSRF randomized token - */ - private string $randomizedToken = '8bc70b67c91494e815c7d2219c1ae0ab005513c290126d34d41bf41c5265e0f1'; - - private SecurityConfig $config; + private const CSRF_PROTECTION_HASH = '8b9218a55906f9dcc1dc263dce7f005a'; + private const CSRF_RANDOMIZED_TOKEN = '8bc70b67c91494e815c7d2219c1ae0ab005513c290126d34d41bf41c5265e0f1'; protected function setUp(): void { parent::setUp(); - Services::injectMock('superglobals', new Superglobals(null, null, null, [])); + Services::injectMock('superglobals', new Superglobals(post: [], cookie: [])); + + $config = new SecurityConfig(); + $config->csrfProtection = Security::CSRF_PROTECTION_COOKIE; + $config->tokenRandomize = true; + Factories::injectMock('config', 'Security', $config); + + $security = new MockSecurity($config); + service('superglobals')->setCookie($security->getCookieName(), self::CSRF_PROTECTION_HASH); - $this->config = new SecurityConfig(); - $this->config->csrfProtection = Security::CSRF_PROTECTION_COOKIE; - $this->config->tokenRandomize = true; - Factories::injectMock('config', 'Security', $this->config); + $this->resetServices(); + } - // Set Cookie value - $security = new MockSecurity($this->config); - service('superglobals')->setCookie($security->getCookieName(), $this->hash); + protected function tearDown(): void + { + parent::tearDown(); $this->resetServices(); + Factories::reset('config'); } public function testTokenIsReadFromCookie(): void { - $security = new MockSecurity($this->config); + $security = new MockSecurity(config('Security')); - $this->assertSame( - $this->randomizedToken, - $security->getHash(), - ); + $this->assertSame(self::CSRF_RANDOMIZED_TOKEN, $security->getHash()); } - public function testCSRFVerifySetNewCookie(): void + public function testCsrfVerifySetNewCookie(): void { - service('superglobals')->setServer('REQUEST_METHOD', 'POST'); - service('superglobals')->setPost('foo', 'bar'); - service('superglobals')->setPost('csrf_test_name', $this->randomizedToken); + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('foo', 'bar') + ->setPost('csrf_test_name', self::CSRF_RANDOMIZED_TOKEN); $config = new MockAppConfig(); $request = new IncomingRequest($config, new SiteURI($config), null, new UserAgent()); - $security = new Security($this->config); + $security = new Security(config('Security')); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); - $this->assertCount(1, service('superglobals')->getPostArray()); - - /** @var Cookie $cookie */ - $cookie = $this->getPrivateProperty($security, 'cookie'); - $newHash = $cookie->getValue(); + $this->assertSame(['foo' => 'bar'], service('superglobals')->getPostArray()); - $this->assertNotSame($this->hash, $newHash); - $this->assertSame(32, strlen($newHash)); + $cookieHash = service('response')->getCookie($security->getCookieName())->getValue(); + $this->assertNotSame(self::CSRF_PROTECTION_HASH, $cookieHash); + $this->assertSame(32, strlen($cookieHash)); } } diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index 932dfc0df2c0..1785341531c1 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -237,6 +237,188 @@ public function testCsrfVerifyHeaderWithJsonBodyStripsTokenFromBody(): void $this->assertSame('{"foo":"bar"}', $request->getBody()); } + public function testCsrfVerifyFetchMetadataSameOriginReturnsSelf(): void + { + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'same-origin'); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF Fetch Metadata verified.'); + } + + public function testCsrfVerifyFetchMetadataRemovesTokenButDoesNotRegenerate(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('foo', 'bar') + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'same-origin'); + + $oldHash = $security->getHash(); + $security->verify($request); + $newHash = $security->getHash(); + + $this->assertSame($oldHash, $newHash); + $this->assertSame(['foo' => 'bar'], service('superglobals')->getPostArray()); + } + + public function testCsrfVerifyFetchMetadataPreservesRawBodyWithoutToken(): void + { + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest() + ->setHeader('Sec-Fetch-Site', 'same-origin') + ->setBody('unchanged'); + + $security->verify($request); + + $this->assertSame('unchanged', $request->getBody()); + } + + public function testCsrfVerifyFetchMetadataSameSiteFallsBackToTokenByDefault(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'same-site'); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + } + + #[DataProvider('provideCsrfVerifyFetchMetadataSameSiteFallsBackToTokenFailureByDefault')] + public function testCsrfVerifyFetchMetadataSameSiteFallsBackToTokenFailureByDefault(?string $token, ?string $cookie): void + { + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + + if ($token !== null) { + service('superglobals')->setPost('csrf_test_name', $token); + } + + if ($cookie !== null) { + service('superglobals')->setCookie('csrf_cookie_name', $cookie); + } + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'same-site'); + + $this->expectException(SecurityException::class); + $security->verify($request); + } + + /** + * @return iterable + */ + public static function provideCsrfVerifyFetchMetadataSameSiteFallsBackToTokenFailureByDefault(): iterable + { + yield 'missing' => [null, null]; + + yield 'invalid' => [self::INVALID_CSRF_HASH, self::CORRECT_CSRF_HASH]; + } + + public function testCsrfVerifyFetchMetadataSameSiteThrowsExceptionWhenRejected(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $config = new SecurityConfig(); + $config->csrfFetchMetadataRejectSameSite = true; + $security = $this->createMockSecurity($config); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'same-site'); + + $this->expectException(SecurityException::class); + $security->verify($request); + } + + public function testCsrfVerifyFetchMetadataCrossSiteThrowsExceptionEvenWithValidToken(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'cross-site'); + + $this->expectException(SecurityException::class); + $security->verify($request); + } + + #[DataProvider('provideCsrfVerifyFetchMetadataFallsBackToToken')] + public function testCsrfVerifyFetchMetadataFallsBackToToken(?string $fetchSite): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest(); + + if ($fetchSite !== null) { + $request->setHeader('Sec-Fetch-Site', $fetchSite); + } + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + } + + /** + * @return iterable + */ + public static function provideCsrfVerifyFetchMetadataFallsBackToToken(): iterable + { + yield 'missing' => [null]; + + yield 'none' => ['none']; + + yield 'unknown' => ['future-value']; + } + + public function testCsrfVerifyTokenOnlyIgnoresFetchMetadata(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $config = new SecurityConfig(); + $config->csrfFetchMetadata = false; + $security = $this->createMockSecurity($config); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'cross-site'); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + } + + public function testCsrfVerifyMissingFetchMetadataConfigFallsBackToTokenOnly(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setPost('csrf_test_name', self::CORRECT_CSRF_HASH) + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $config = new SecurityConfig(); + unset($config->csrfFetchMetadata); + + $security = $this->createMockSecurity($config); + $request = $this->createIncomingRequest()->setHeader('Sec-Fetch-Site', 'cross-site'); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + } + public function testCsrfVerifyPutBodyThrowsExceptionOnNoMatch(): void { service('superglobals') @@ -273,15 +455,6 @@ public function testCsrfVerifyPutBodyReturnsSelfOnMatch(): void $this->assertSame('foo=bar', $request->getBody()); } - public function testSanitizeFilename(): void - { - $security = $this->createMockSecurity(); - - $filename = './'; - - $this->assertSame('foo', $security->sanitizeFilename($filename)); - } - public function testRegenerateWithFalseSecurityRegenerateProperty(): void { service('superglobals') diff --git a/tests/system/Test/TestCaseEmissionsTest.php b/tests/system/Test/TestCaseEmissionsTest.php index 8ebcdcc28d92..567b5daa8ddf 100644 --- a/tests/system/Test/TestCaseEmissionsTest.php +++ b/tests/system/Test/TestCaseEmissionsTest.php @@ -14,7 +14,6 @@ namespace CodeIgniter\Test; use CodeIgniter\HTTP\Response; -use Config\App; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -49,7 +48,7 @@ final class TestCaseEmissionsTest extends CIUnitTestCase #[WithoutErrorHandler] public function testHeadersEmitted(): void { - $response = new Response(new App()); + $response = new Response(); $response->pretend(false); $body = 'Hello'; @@ -76,7 +75,7 @@ public function testHeadersEmitted(): void #[WithoutErrorHandler] public function testHeadersNotEmitted(): void { - $response = new Response(new App()); + $response = new Response(); $response->pretend(false); $body = 'Hello'; diff --git a/tests/system/Test/TestCaseTest.php b/tests/system/Test/TestCaseTest.php index 6905d825533a..893caaa9119d 100644 --- a/tests/system/Test/TestCaseTest.php +++ b/tests/system/Test/TestCaseTest.php @@ -93,4 +93,23 @@ public function testCloseEnoughStringBadLength(): void $result = $this->assertCloseEnoughString('apples & oranges', 'apples'); $this->assertFalse($result, 'Different string lengths should have returned false'); } + + public function testAssertSameSqlIgnoresNewlinesInActualSql(): void + { + $expected = 'SELECT * FROM "jobs" WHERE "id" = 1'; + $actual = <<<'SQL' + SELECT * FROM "jobs" + WHERE "id" = 1 + SQL; + + $this->assertSameSql($expected, $actual); + } + + public function testAssertSameSqlIgnoresCrLfInActualSql(): void + { + $expected = 'SELECT * FROM "jobs" WHERE "id" = 1'; + $actual = "SELECT * FROM \"jobs\"\r\nWHERE \"id\" = 1"; + + $this->assertSameSql($expected, $actual); + } } diff --git a/tests/system/Test/TestResponseTest.php b/tests/system/Test/TestResponseTest.php index dc175897b870..442d5239b713 100644 --- a/tests/system/Test/TestResponseTest.php +++ b/tests/system/Test/TestResponseTest.php @@ -15,7 +15,6 @@ use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Response; -use Config\App; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -154,7 +153,7 @@ public function testAssertRedirectFail(): void public function testAssertRedirectSuccess(): void { $this->getTestResponse('

Hello World

'); - $this->testResponse->setResponse(new RedirectResponse(new App())); + $this->testResponse->setResponse(new RedirectResponse()); $this->assertInstanceOf(RedirectResponse::class, $this->testResponse->response()); $this->assertTrue($this->testResponse->isRedirect()); @@ -175,7 +174,7 @@ public function testAssertRedirectSuccessWithoutRedirectResponse(): void public function testGetRedirectUrlReturnsUrl(): void { $this->getTestResponse('

Hello World

'); - $this->testResponse->setResponse(new RedirectResponse(new App())); + $this->testResponse->setResponse(new RedirectResponse()); $this->testResponse->response()->redirect('foo/bar'); $this->assertSame('foo/bar', $this->testResponse->getRedirectUrl()); @@ -191,7 +190,7 @@ public function testGetRedirectUrlReturnsNull(): void public function testRedirectToSuccess(): void { $this->getTestResponse('

Hello World

'); - $this->testResponse->setResponse(new RedirectResponse(new App())); + $this->testResponse->setResponse(new RedirectResponse()); $this->testResponse->response()->redirect('foo/bar'); $this->testResponse->assertRedirectTo('foo/bar'); @@ -200,7 +199,7 @@ public function testRedirectToSuccess(): void public function testRedirectToSuccessFullURL(): void { $this->getTestResponse('

Hello World

'); - $this->testResponse->setResponse(new RedirectResponse(new App())); + $this->testResponse->setResponse(new RedirectResponse()); $this->testResponse->response()->redirect('http://foo.com/bar'); $this->testResponse->assertRedirectTo('http://foo.com/bar'); @@ -209,7 +208,7 @@ public function testRedirectToSuccessFullURL(): void public function testRedirectToSuccessMixedURL(): void { $this->getTestResponse('

Hello World

'); - $this->testResponse->setResponse(new RedirectResponse(new App())); + $this->testResponse->setResponse(new RedirectResponse()); $this->testResponse->response()->redirect('bar'); $this->testResponse->assertRedirectTo('http://example.com/index.php/bar'); @@ -434,7 +433,7 @@ public function testAssertJsonExactString(): void protected function getTestResponse(?string $body = null, array $responseOptions = [], array $headers = []): void { - $this->response = new Response(new App()); + $this->response = new Response(); $this->response->setBody($body); foreach ($responseOptions as $key => $value) { diff --git a/tests/system/Validation/StrictRules/FileRulesTest.php b/tests/system/Validation/StrictRules/FileRulesTest.php index f7b5543b6f9f..8f65a0e5381d 100644 --- a/tests/system/Validation/StrictRules/FileRulesTest.php +++ b/tests/system/Validation/StrictRules/FileRulesTest.php @@ -13,10 +13,14 @@ namespace CodeIgniter\Validation\StrictRules; +use CodeIgniter\Config\Services; +use CodeIgniter\EnvironmentDetector; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Validation\Validation; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use Tests\Support\Validation\TestRules; /** @@ -64,7 +68,7 @@ protected function setUp(): void 'name' => 'my-avatar.png', 'size' => 4614, 'type' => 'image/png', - 'error' => 0, + 'error' => UPLOAD_ERR_OK, 'width' => 640, 'height' => 400, ], @@ -152,28 +156,46 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); + service('superglobals')->setFilesArray([]); + Services::resetSingle('environment'); } - public function testUploadedTrue(): void + public function testUploadedPassesForSingleValidFile(): void { $this->validation->setRules(['avatar' => 'uploaded[avatar]']); $this->assertTrue($this->validation->run([])); } - public function testUploadedFalse(): void + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testUploadedFailsInProductionWhenFileWasNotHttpUpload(): void + { + // Counterpart to testUploadedPassesForSingleValidFile: the same fixture + // passes in the testing env but must fail in production, where isValid() + // enforces is_uploaded_file(). Runs in a separate process because the + // namespace-level is_uploaded_file() override in FileMovingTest.php would + // otherwise leak in and make the fixture appear to be a valid upload. + Services::injectMock('environment', new EnvironmentDetector('production')); + + $this->validation->setRules(['avatar' => 'uploaded[avatar]']); + + $this->assertFalse($this->validation->run([])); + } + + public function testUploadedFailsWhenFileIsMissingFromRequest(): void { $this->validation->setRules(['avatar' => 'uploaded[userfile]']); $this->assertFalse($this->validation->run([])); } - public function testUploadedArrayReturnsTrue(): void + public function testUploadedPassesWhenAllFilesInArrayAreValid(): void { $this->validation->setRules(['images' => 'uploaded[images]']); $this->assertTrue($this->validation->run([])); } - public function testUploadedArrayReturnsFalse(): void + public function testUploadedFailsWhenAnyFileInArrayHasUploadError(): void { $this->validation->setRules(['photos' => 'uploaded[photos]']); $this->assertFalse($this->validation->run([])); diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 7cf70deaeb27..d0483a890ed8 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Input\ValidatedInput; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Validation\Exceptions\ValidationException; @@ -253,6 +254,17 @@ public function testRunReturnsFalseWithNothingToDo(): void $this->assertSame([], $this->validation->getValidated()); } + public function testGetValidatedInputReturnsValidatedInputObject(): void + { + $this->validation->setRules(['role' => 'required']); + $this->assertTrue($this->validation->run(['role' => 'administrator'])); + + $input = $this->validation->getValidatedInput(); + + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame('administrator', $input->get('role')); + } + public function testRuleClassesInstantiatedOnce(): void { $this->validation->setRules([]); @@ -342,6 +354,35 @@ static function ($value, $data, &$error, $field): bool { $this->assertSame([], $this->validation->getValidated()); } + public function testClosureRuleWithParamErrorPlaceholders(): void + { + $this->validation->setRules([ + 'status' => [ + 'label' => 'Status', + 'rules' => [ + static function ($value, $data, &$error, $field): bool { + if ($value !== 'active') { + $error = 'The field {field} must be one of: {param}. Received: {value}'; + + return false; + } + + return true; + }, + ], + ], + ]); + + $data = ['status' => 'invalid']; + $result = $this->validation->run($data); + + $this->assertFalse($result); + $this->assertSame( + ['status' => 'The field Status must be one of: . Received: invalid'], + $this->validation->getErrors(), + ); + } + public function testClosureRuleWithLabel(): void { $this->validation->setRules([ @@ -415,6 +456,22 @@ public function rule2(mixed $value, array $data, ?string &$error, string $field) return true; } + /** + * Validation rule3 + * + * @param array $data + */ + public function rule3(mixed $value, array $data, ?string &$error, string $field): bool + { + if ($value !== 'active') { + $error = 'The field {field} must be one of: {param}. Received: {value}'; + + return false; + } + + return true; + } + public function testCallableRuleWithParamError(): void { $this->validation->setRules([ @@ -435,6 +492,68 @@ public function testCallableRuleWithParamError(): void $this->assertSame([], $this->validation->getValidated()); } + public function testCallableRuleWithParamErrorPlaceholders(): void + { + $this->validation->setRules([ + 'status' => [ + 'label' => 'Status', + 'rules' => [$this->rule3(...)], + ], + ]); + + $data = ['status' => 'invalid']; + $result = $this->validation->run($data); + + $this->assertFalse($result); + $this->assertSame( + ['status' => 'The field Status must be one of: . Received: invalid'], + $this->validation->getErrors(), + ); + } + + public function testRuleSetRuleWithParamErrorPlaceholders(): void + { + $this->validation->setRules([ + 'status' => [ + 'label' => 'Status', + 'rules' => 'custom_error_with_param[active,inactive]', + ], + ]); + + $data = ['status' => 'invalid']; + $result = $this->validation->run($data); + + $this->assertFalse($result); + $this->assertSame( + ['status' => 'The Status must be one of: active,inactive. Got: invalid'], + $this->validation->getErrors(), + ); + } + + public function testClosureRuleErrorWithUnknownPlaceholderPreserved(): void + { + $this->validation->setRules([ + 'status' => [ + 'rules' => [ + static function ($value, $data, &$error, $field): bool { + $error = 'Value {value} is invalid. See {link} for details.'; + + return false; + }, + ], + ], + ]); + + $data = ['status' => 'bad']; + $result = $this->validation->run($data); + + $this->assertFalse($result); + $this->assertSame( + ['status' => 'Value bad is invalid. See {link} for details.'], + $this->validation->getErrors(), + ); + } + public function testCallableRuleWithLabel(): void { $this->validation->setRules([ diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index ca8f8c2cb715..157550b61ec5 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.8.0 v4.7.4 v4.7.3 v4.7.2 diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst new file mode 100644 index 000000000000..569daf8f5c43 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -0,0 +1,387 @@ +############# +Version 4.8.0 +############# + +Release Date: Unreleased + +**4.8.0 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 3 + +********** +Highlights +********** + +- TBD + +******** +BREAKING +******** + +Behavior Changes +================ + +- The static ``Boot::initializeConsole()`` method no longer handles the display of the console header. This is now handled within ``Console::run()``. + If you have overridden ``Boot::initializeConsole()``, you should remove any code related to displaying the console header, as this is now the responsibility of the ``Console`` class. +- **Commands:** The ``-h`` option to ``routes`` command which was mapped previously to the ``--sort-by-handler`` option is now removed. + Use ``--sort-by-handler`` instead to sort the routes by handler when running the ``routes`` command. +- **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``). +- **Commands:** Several built-in commands have been migrated from ``BaseCommand`` to the modern ``AbstractCommand`` style. Applications that extend a built-in command to override + behaviour may need to re-implement against the modern API (``configure()`` + ``execute()`` and the ``#[Command]`` attribute) once the class it extends is migrated, or, preferably, compose instead of extending. Invocations on the command line are unaffected. +- **Commands:** The success and error messages from ``debugbar:clear``, ``cache:clear``, and ``cache:info`` now include the affected path or cache driver/handler so the user can see which resource was acted on (or rejected). Scripts asserting on the prior literal text will need to be updated. +- **Commands:** Declining the ``key:generate`` overwrite prompt interactively now returns ``EXIT_SUCCESS`` instead of ``EXIT_ERROR``. Output messages were also reworded; CI/automation that branches on the exit code or greps the previous wording will need updating. +- **Commands:** The ``migrate:rollback`` command no longer accepts the undocumented ``-g`` (database group) option. It never had any effect, since ``MigrationRunner::regress()`` ignores the group, and the modern command pipeline now rejects unknown options. Remove ``-g`` from any ``migrate:rollback`` invocation. +- **Commands:** The ``logs:clear`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. Output messages have also been reworded to distinguish cancellation (interactive ``n``) from abort (non-interactive without ``--force``), and the resolved log directory path is now included in the prompt, success, and failure messages. +- **Commands:** The ``db:table`` command now returns ``EXIT_SUCCESS`` from ``db:table --show`` (previously ``EXIT_ERROR``), and reports an error instead of prompting when run non-interactively (``--no-interaction`` or piped input) without a valid table. CI/automation that branches on the ``--show`` exit code will need updating. +- **Commands:** The ``db:table`` output was reworded: the data and metadata column headers are now capitalized (e.g. ``Id``, ``Created_at``), the connection summary headers read ``Hostname``, ``Database``, ``Username``, ``DB Driver``, ``DB Prefix``, and ``Port``, and the section titles changed (e.g. ``Data of "users" table:``). Scripts that grep the previous output will need updating. +- **Commands:** The ``worker:uninstall`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. In non-interactive mode without ``--force`` it now aborts with ``EXIT_ERROR`` instead of prompting, and the redundant ``Uninstalling FrankenPHP Worker Mode`` header was dropped. Scripts that branch on the previous exit code or wording will need updating. +- **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. +- **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method + (e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match. +- **Testing:** Tests using the ``FeatureTestTrait`` must now use uppercase HTTP method names when performing a request when using the ``call()`` method directly + (e.g., ``$this->call('GET', '/path')`` instead of ``$this->call('get', '/path')``). Additionally, setting method-based routes using ``withRoutes()`` must + also use uppercase method names (e.g., ``$this->withRoutes([['GET', 'home', 'Home::index']])``). + +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 or method changes to ensure compatibility. + +- **Cache:** ``CodeIgniter\Cache\CacheInterface::remember()`` now accepts a TTL callable. Custom implementations of ``CacheInterface`` must update the ``$ttl`` parameter type from ``int`` to ``callable|int``. +- **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, ``inTransaction()``, and ``transaction()`` methods. +- **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. +- **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. +- **Validation:** ``CodeIgniter\Validation\ValidationInterface`` now requires the ``getValidatedInput()`` method, which returns a ``CodeIgniter\Input\ValidatedInput`` instance. + +Method Signature Changes +======================== + +- **CLI:** The ``Console::run()`` method now accepts an optional ``array $tokens`` parameter. This allows you to pass an array of command tokens directly to the console runner, which is useful for testing or programmatically running commands. If not provided, it will default to using the global ``$argv``. +- **CodeIgniter:** The deprecated parameters in methods have been removed: + - ``CodeIgniter\CodeIgniter::handleRequest()`` no longer accepts the deprecated ``$cacheConfig`` and ``$returnResponse`` parameters. + - ``$cacheConfig`` is no longer used and is now hard deprecated. A deprecation notice will be triggered if this is passed to the method. + - ``$returnResponse`` is now removed since already deprecated and unused. + - The updated method signature is now ``handleRequest(?RouteCollectionInterface $routes, ?Cache $cacheConfig = null)``. + - ``CodeIgniter\CodeIgniter::gatherOutput()`` no longer accepts the deprecated ``$cacheConfig`` parameter. + As this is the first parameter, custom uses of this method will need to be updated to remove the parameter. +- **Config:** ``CodeIgniter\Config\Services::request()`` no longer accepts any parameter. +- **Database:** The following methods have had their signatures updated to remove deprecated parameters: + - ``CodeIgniter\Database\Forge::_createTable()`` no longer accepts the deprecated ``$ifNotExists`` parameter. The method signature is now ``_createTable(string $table, array $attributes)``. + +Property Scope Changes +====================== + +- **HTTP:** The following properties have changed their scope (visibility): + - ``CodeIgniter\HTTP\RequestTrait::$ipAddress`` is now private (previously protected). + +Method Scope Changes +==================== + +- **HTTP:** The following methods have changed their scope (visibility): + - ``CodeIgniter\HTTP\URI::setUri()`` is now private (previously public). + - ``CodeIgniter\HTTP\URI::refreshPath()`` is now protected (previously public). + - ``CodeIgniter\HTTP\SiteURI::refreshPath()`` is now protected (previously public). + +Removed Deprecated Items +======================== + +- **Autoloader:** Removed the following deprecated methods: + - ``CodeIgniter\Autoloader\Autoloader::sanitizeFileName()`` + - ``CodeIgniter\Autoloader\Autoloader::discoverComposerNamespaces()`` +- **Bootstrap:** The deprecated **system/bootstrap.php** file has been removed. +- **Cache:** Removed the following deprecated methods and constant: + - ``CodeIgniter\Cache\Handlers\BaseHandler::RESERVED_CHARACTERS`` (deprecated since v4.1.5) + - ``CodeIgniter\Cache\Handlers\FileHandler::writeFile()`` (deprecated since v4.6.0) + - ``CodeIgniter\Cache\Handlers\FileHandler::deleteFile()`` (deprecated since v4.6.0) + - ``CodeIgniter\Cache\Handlers\FileHandler::getDirFileInfo()`` (deprecated since v4.6.0) + - ``CodeIgniter\Cache\Handlers\FileHandler::getFileInfo()`` (deprecated since v4.6.0) +- **CLI:** Removed the following properties and methods deprecated: + - ``CodeIgniter\CLI\BaseCommand::getPad()`` + - ``CodeIgniter\CLI\CLI::$readline_support`` + - ``CodeIgniter\CLI\CLI::$wait_msg`` + - ``CodeIgniter\CLI\CLI::isWindows()`` + - ``CodeIgniter\CLI\GeneratorTrait::execute()`` +- **CodeIgniter:** Removed the following properties and methods deprecated: + - ``CodeIgniter\CodeIgniter::$cacheTTL`` + - ``CodeIgniter\CodeIgniter::$returnResponse`` + - ``CodeIgniter\CodeIgniter::initializeKint()`` + - ``CodeIgniter\CodeIgniter::detectEnvironment()`` + - ``CodeIgniter\CodeIgniter::bootstrapEnvironment()`` + - ``CodeIgniter\CodeIgniter::forceSecureAccess()`` + - ``CodeIgniter\CodeIgniter::displayCache()`` + - ``CodeIgniter\CodeIgniter::cache()`` + - ``CodeIgniter\CodeIgniter::cachePage()`` + - ``CodeIgniter\CodeIgniter::generateCacheName()`` + - ``CodeIgniter\CodeIgniter::displayPerformanceMetrics()`` + - ``CodeIgniter\CodeIgniter::determinePath()`` + - ``CodeIgniter\CodeIgniter::callExit()`` + - ``CodeIgniter\Test\MockCodeIgniter::callExit()`` +- **Config:** Removed the following property deprecated: + - ``CodeIgniter\Config\BaseService::$services`` (deprecated since v4.5.0) +- **Database:** Removed the following properties and methods deprecated: + - ``CodeIgniter\Database\BaseBuilder::setInsertBatch()`` + - ``CodeIgniter\Database\BaseBuilder::setUpdateBatch()`` + - ``CodeIgniter\Database\BaseBuilder::cleanClone()`` + - ``CodeIgniter\Database\BaseConnection::$strictOn`` + - ``CodeIgniter\Database\Forge::$createTableIfStr`` + - ``CodeIgniter\Database\Seeder::$faker`` + - ``CodeIgniter\Database\Seeder::faker()`` + - ``CodeIgniter\Database\OCI8\Forge::$createTableIfStr`` + - ``CodeIgniter\Database\OCI8\Forge::getError()`` + - ``CodeIgniter\Database\SQLSRV\Forge::$createTableIfStr`` +- **Debug:** Removed the following deprecated properties and methods: + - ``CodeIgniter\Debug\Toolbar\Collectors\BaseCollector::cleanPath()`` (deprecated since v4.2.0) + - ``CodeIgniter\Debug\Exceptions::$ob_level`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::$viewPath`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::determineView()`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::render()`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::collectVars()`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::maskSensitiveData()`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::maskData()`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::cleanPath()`` (deprecated since v4.2.0) + - ``CodeIgniter\Debug\Exceptions::describeMemory()`` (deprecated since v4.4.0) + - ``CodeIgniter\Debug\Exceptions::highlightFile()`` (deprecated since v4.4.0) +- **Exceptions:** Removed the following static constructors of ``FrameworkException`` and its child classes: + - ``CodeIgniter\Exceptions\DownloadException::forCannotSetCache()`` + - ``CodeIgniter\Exceptions\FrameworkException::forMissingExtension()`` + - ``CodeIgniter\Honeypot\Exceptions\HoneypotException::forNoHiddenValue()`` + - ``CodeIgniter\HTTP\Exceptions\HTTPException::forInvalidSameSiteSetting()`` + - ``CodeIgniter\Security\Exceptions\SecurityException::forInvalidSameSite()`` + - ``CodeIgniter\Session\Exceptions\SessionException::forInvalidSameSiteSetting()`` +- **Filters:** Removed the following properties and methods deprecated: + - ``CodeIgniter\Filters\Filters::$arguments`` (deprecated since v4.6.0) + - ``CodeIgniter\Filters\Filters::$argumentsClass`` (deprecated since v4.6.0) + - ``CodeIgniter\Filters\Filters::getArguments()`` (deprecated since v4.6.0) +- **HTTP:** Removed the following properties and methods deprecated: + - ``CodeIgniter\HTTP\Message::getHeaders()`` (deprecated since v4.0.5) + - ``CodeIgniter\HTTP\Message::getHeader()`` (deprecated since v4.0.5) + - ``CodeIgniter\HTTP\RequestTrait::getEnv()`` (deprecated since v4.4.4) + - ``CodeIgniter\HTTP\URI::$uriString`` (deprecated since v4.4.0) + - ``CodeIgniter\HTTP\URI::$baseURL`` (deprecated since v4.4.0) + - ``CodeIgniter\HTTP\URI::setBaseURL()`` (deprecated since v4.4.0) + - ``CodeIgniter\HTTP\URI::getBaseURL()`` (deprecated since v4.4.0) + - ``CodeIgniter\HTTP\URI::setScheme()`` (deprecated since v4.4.0) + - ``CodeIgniter\HTTP\SiteURI::$segments`` (deprecated since v4.4.0) + - ``CodeIgniter\HTTP\SiteURI::setBaseURL()`` (deprecated since v4.4.0) + - ``CodeIgniter\HTTP\SiteURI::setURI()`` (deprecated since v4.4.0) +- **Security:** Removed the following properties and methods deprecated: + - ``CodeIgniter\Security\SecurityInterface::sanitizeFilename()`` (deprecated since v4.6.2) + - ``CodeIgniter\Security\Security::sanitizeFilename()`` (deprecated since v4.6.2) + - ``CodeIgniter\Security\Security::$csrfProtection`` (deprecated since v4.4.0) + - ``CodeIgniter\Security\Security::$tokenRandomize`` (deprecated since v4.4.0) + - ``CodeIgniter\Security\Security::$tokenName`` (deprecated since v4.4.0) + - ``CodeIgniter\Security\Security::$headerName`` (deprecated since v4.4.0) + - ``CodeIgniter\Security\Security::$expires`` (deprecated since v4.4.0) + - ``CodeIgniter\Security\Security::$regenerate`` (deprecated since v4.4.0) + - ``CodeIgniter\Security\Security::$redirect`` (deprecated since v4.4.0) + - ``CodeIgniter\Security\Security::$sameSite`` (deprecated since v4.4.0) + +************ +Enhancements +************ + +API +=== + +- Added per-type sparse fieldsets to API transformers. A transformer may declare a ``$type`` so clients + can request ``?fields[]=...`` to filter the fields of that resource type independently, including + for nested/related resources, while the flat ``?fields=...`` continues to apply to the root resource only. + See :ref:`api_transformers`. + +Commands +======== + +- Added a new attribute-based command style built on :php:class:`AbstractCommand ` and the ``#[Command]`` attribute, + with ``configure()`` / ``isAvailable()`` / ``initialize()`` / ``interact()`` / ``execute()`` hooks and typed ``Argument`` / ``Option`` definitions. + The legacy ``BaseCommand`` style continues to work. See :doc:`../cli/cli_modern_commands`. +- Modern commands can now declare command aliases through an ``aliases`` list on the ``#[Command]`` attribute. Aliases resolve to the command + at dispatch (``php spark `` and ``help ``), are listed as their own rows in ``spark list``, and appear in an ``Aliases:`` section + of ``help ``. An alias that collides with an existing command name or another alias is rejected at discovery. See :doc:`../cli/cli_modern_commands`. +- Every modern command now ships with a ``--no-interaction`` / ``-N`` flag that skips the ``interact()`` hook, plus public + ``isInteractive()`` / ``setInteractive()`` methods on ``AbstractCommand``. ``isInteractive()`` also auto-detects piped or + CI environments by probing STDIN for a TTY, and the state cascades to sub-commands invoked via ``$this->call(...)``. + See :ref:`non-interactive-mode`. +- ``spark help `` now shows the default value of each option that accepts a value (e.g., ``[default: "file"]``), + the same way argument defaults are already displayed. Boolean flags and negatable toggles, which have no meaningful default to show, are unaffected. +- You can now retrieve the last executed command in the console using the new ``Console::getCommand()`` method. This is useful for logging, debugging, or any situation where you need to know which command was run. +- ``CLI`` now supports the ``--`` separator to mean that what follows are arguments, not options. This allows you to have arguments that start with ``-`` without them being treated as options. + For example: ``spark my:command -- --myarg`` will pass ``--myarg`` as an argument instead of an option. +- ``CLI`` now supports options with values specified using an equals sign (e.g., ``--option=value``) in addition to the existing space-separated syntax (e.g., ``--option value``). + This provides more flexibility in how you can pass options to commands. +- ``CLI`` now supports parsing array options written multiple times (e.g., ``--option=value1 --option=value2``) into an array of values. This allows you to easily pass multiple values for the same option without needing to use a comma-separated string. + When used with ``CLI::getOption()``, an array option will return its last value (for example, in this case, ``value2``). To retrieve all values for an array option, use ``CLI::getRawOption()``. +- Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code. +- Added ``make:request`` generator command to scaffold :ref:`Form Request ` classes. +- Added ``key:rotate`` command to demote the current ``encryption.key`` to ``encryption.previousKeys`` in **.env** and generate a new key. See :ref:`spark-key-rotate`. +- Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`. +- The ``migrate``, ``migrate:rollback``, ``migrate:refresh``, and ``migrate:status`` commands now accept long option names (``--namespace``, ``--group``, ``--batch``, ``--force``) alongside their existing short forms (``-n``, ``-g``, ``-b``, ``-f``). +- The ``worker:install`` and ``worker:uninstall`` commands now accept the ``-f`` short option alongside ``--force``. +- Added :php:class:`NullInputOutput `, an :php:class:`InputOutput ` sink that discards all writes and returns an empty string from ``input()``. +- The ``spark routes`` command now resolves the Before/After Filters columns for routes that use custom placeholders registered via ``$routes->addPlaceholder()``. + Sample URIs for custom placeholders are derived from the placeholder regex when possible, and can be overridden through the new ``$placeholderSamples`` property in ``Config\Routing``. See :ref:`placeholder-samples-for-spark-routes`. + +Testing +======= + +- Added ``assertSameSql()`` to ``CIUnitTestCase`` to compare generated SQL while ignoring newlines in the actual SQL. + +Database +======== + +- Added ``afterCommit()`` and ``afterRollback()`` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. See :ref:`transactions-transaction-callbacks`. +- Added ``inTransaction()`` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See :ref:`transactions-checking-transaction-state`. +- Added support for PHP ``BackedEnum`` values in database escaping, query bindings, and Query Builder bound values. +- Prepared query execution failures now throw or store typed database exceptions such as ``UniqueConstraintViolationException`` and ``RetryableTransactionException`` when applicable, matching normal query failures. +- Added ``RetryableTransactionException`` for driver-specific retryable transaction failures such as deadlocks and serialization failures. See :ref:`transactions-retryable-exceptions`. +- Added the ``transaction()`` method to database connections to run a callback inside a transaction, with optional retry attempts for retryable transaction failures and scoped ``transException`` and ``resetTransStatus`` options. See :ref:`transactions-closure`. +- Added ``trustServerCertificate`` option to ``SQLSRV`` database connections in ``Config\Database``. Set it to ``true`` to trust the server certificate without CA validation when using encrypted connections. + +Query Builder +------------- + +- Added ``exists()`` and ``doesntExist()`` to Query Builder to check whether the current Query Builder query would return at least one row. See :ref:`query-builder-exists`. +- Added ``explain()`` to Query Builder to run execution-plan queries for the current ``SELECT`` query. See :ref:`query-builder-explain`. +- Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`. +- Added ``likeAny()`` and ``orLikeAny()`` to Query Builder to search one value across multiple fields with grouped ``OR`` ``LIKE`` conditions. See :ref:`query-builder-like-any`. +- Added ``sharedLock()`` to add pessimistic read locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-shared-lock`. +- Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`. +- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. +- Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`. +- Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations. +- Added ``lockForUpdate()`` to add pessimistic write locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-lock-for-update`. + +Forge +----- + +Others +------ + +- Added new ``timezone`` option to connection array in ``Config\Database`` config. This ensures consistent timestamps between model operations and database functions like ``NOW()``. Supported drivers: **MySQLi**, **Postgre**, and **OCI8**. See :ref:`database-config-timezone` for details. +- Added :php:class:`UniqueConstraintViolationException ` which extends ``DatabaseException`` and is thrown on duplicate key (unique constraint) violations across all database drivers. See :ref:`database-unique-constraint-violation`. +- Added ``$db->getLastException()`` which returns the typed exception even when ``DBDebug`` is ``false``. See :ref:`database-get-last-exception`. +- Added ``DatabaseException::getDatabaseCode()`` returning the native driver error code as ``int|string``; ``getCode()`` is constrained to ``int`` by PHP's ``Throwable`` interface and cannot carry string SQLSTATE codes. + +Debug +===== + +- Added a **Copy Details** button to detailed HTML exception pages. + +Model +===== + +- Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks. +- Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given attributes or inserts a new one. See :ref:`model-first-or-insert`. +- Added ``$throwOnDisallowedFields`` and ``throwOnDisallowedFields()`` to ``CodeIgniter\Model`` to throw a ``DataException`` when write data contains fields that would otherwise be discarded by ``$allowedFields``. See :ref:`model-throw-on-disallowed-fields`. + +Libraries +========= + +- **Cache:** Added support for TTL callables in the ``remember()`` method of cache handlers. This allows you to specify a callable that returns a TTL value, which can be useful for dynamic TTL. +- **Context**: This new feature allows you to easily set and retrieve normal or hidden contextual data for the current request. See :ref:`Context ` for details. +- **Images:**: Added support for the AVIF file format. +- **Locks:** Added :doc:`Atomic Locks ` for owner-aware, cross-process mutual exclusion backed by supported cache handlers: **File**, **Redis**, **Predis**, and **Memcached**. Memcached support has driver-specific release limitations because Memcached has no atomic compare-and-delete command. +- **Logging:** Log handlers now receive the full context array as a third argument to ``handle()``. When ``$logGlobalContext`` is enabled, the CI global context is available under the ``HandlerInterface::GLOBAL_CONTEXT_KEY`` key. Built-in handlers append it to the log output; custom handlers can use it for structured logging. +- **Logging:** Added :ref:`per-call context logging ` with three new ``Config\Logger`` options (``$logContext``, ``$logContextTrace``, ``$logContextUsedKeys``). Per PSR-3, a ``Throwable`` in the ``exception`` context key is automatically normalized to a meaningful array. All options default to ``false``. +- **Security:** Added :ref:`Fetch Metadata based CSRF protection ` with token fallback. + +Helpers and Functions +===================== + +- :doc:`Array Helper ` gained five new dot-path functions: + :php:func:`dot_array_has()`, :php:func:`dot_array_set()`, :php:func:`dot_array_unset()`, + :php:func:`dot_array_only()`, and :php:func:`dot_array_except()`. +- :doc:`Array Helper ` dot-path read operations now support + object rows (including ``Entity``) in :php:func:`dot_array_search()`, + :php:func:`dot_array_has()`, :php:func:`dot_array_only()`, + :php:func:`dot_array_except()`, and :php:func:`array_group_by()`. + :php:func:`dot_array_only()` and :php:func:`dot_array_except()` still return arrays. If the source itself is an object, + it is read as an array-like value. Object values inside the source are kept unchanged when selected as a whole or left untouched. + Partial object paths are returned as arrays. + +HTTP +==== + +- Added the ``retry`` option to ``CURLRequest`` for retrying failed responses with configurable delays, retryable status codes, optional transient cURL error retries, and ``Retry-After`` support. See :ref:`curlrequest-request-options-retry`. +- Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, and authorization logic for a single HTTP request. +- Added ``IncomingRequest::input()`` to read GET, POST, JSON, and raw request data through ``InputData``. +- Added ``SSEResponse`` class for streaming Server-Sent Events (SSE) over HTTP. See :ref:`server-sent-events`. +- ``Response`` and its child classes no longer require ``Config\App`` passed to their constructors. + Consequently, ``CURLRequest``'s ``$config`` parameter is unused and will be removed in a future release. +- ``Response`` now lazily loads the ``ContentSecurityPolicy``, ``Cookie``, and ``CookieStore`` classes only when used, + instead of instantiating them on every request in the constructor. Requests that do not touch CSP or cookies no longer + incur the cost of loading these classes. +- ``CLIRequest`` now supports the ``--`` separator to mean that what follows are arguments, not options. This allows you to have arguments that start with ``-`` without them being treated as options. + For example: ``php index.php command -- --myarg`` will pass ``--myarg`` as an argument instead of an option. +- ``CLIRequest`` now supports options with values specified using an equals sign (e.g., ``--option=value``) in addition to the existing space-separated syntax (e.g., ``--option value``). + This provides more flexibility in how you can pass options to CLI requests. +- Added ``$enableStyleNonce`` and ``$enableScriptNonce`` options to ``Config\App`` to automatically add nonces to control whether to add nonces to style-* and script-* directives in the Content Security Policy (CSP) header when CSP is enabled. See :ref:`csp-control-nonce-generation` for details. +- Added ``URI::withQuery()``, ``URI::withQueryArray()``, ``URI::withoutQueryVars()``, and ``URI::withOnlyQueryVars()`` to return cloned URIs with replaced or filtered query variables. +- Added ``URI::withQueryVar()`` and ``URI::withQueryVars()`` to return a cloned URI with query variables added or replaced. +- ``URI`` now accepts an optional boolean second parameter in the constructor, defaulting to ``false``, to control how the query string is parsed in instantiation. + This is the behavior of ``->useRawQueryString()`` brought into the constructor for convenience. Previously, you need to call ``$uri->useRawQueryString(true)->setURI($uri)`` to get this behavior. + Now you can simply do ``new URI($uri, true)``. +- ``CLIRequest`` now supports parsing array options written multiple times (e.g., ``--option=value1 --option=value2``) into an array of values. This allows you to easily pass multiple values for the same option without needing to use a comma-separated string. + When used with ``CLIRequest::getOption()``, an array option will return its last value (for example, in this case, ``value2``). To retrieve all values for an array option, use ``CLIRequest::getRawOption()``. + +Validation +========== + +- Custom rule methods that set an error via the ``&$error`` reference parameter now support the ``{field}``, ``{param}``, and ``{value}`` placeholders, consistent with language-file and ``setRule()``/``setRules()`` error messages. +- Added ``Validation::getValidatedInput()`` to access validated data through a ``ValidatedInput`` object. + +Others +====== + +- **Config:** Added optional ``CodeIgniter\Config\Merge`` directives for :ref:`Registrars ` to control replacing, deep merging, and ordered list additions. Existing registrar merge behavior is unchanged. See :ref:`registrar-merge-directives`. +- **Float and Double Casting:** Added support for precision and rounding mode when casting to float or double in entities. +- Added ``CodeIgniter\Input\InputData``, ``ValidatedInput``, and ``InputDataFactory`` for reusable typed input data objects. +- Float and Double casting now throws ``CastException::forInvalidFloatRoundingMode()`` if an rounding mode other than up, down, even or odd is provided. +- **Filters:** Added ``RequestId`` filter for request tracing and correlation logging. The filter stores the request ID in the request context and automatically adds the ``X-Request-ID`` response header. Incoming ``X-Request-ID`` headers are used when valid. See :ref:`requestid` for details. +- **Environment:** Added ``CodeIgniter\EnvironmentDetector`` class and corresponding ``environment`` service as a mockable wrapper around the ``ENVIRONMENT`` constant. + Framework internals that previously compared ``ENVIRONMENT`` directly now go through this service, making environment-specific branches reachable in tests via ``Services::injectMock()``. See :ref:`environment-detector-service`. +- **Time:** Added ``Time::between()``, ``Time::min()``, and ``Time::max()`` comparison helpers. See :ref:`between `, :ref:`min `, and :ref:`max `. + +*************** +Message Changes +*************** + +- Added new language keys: + - ``Cache.unsupportedLockStore`` (``CacheException::forUnsupportedLockStore()``) + - ``CLI.commandAlias`` and ``CLI.helpAliases`` (command alias rendering in ``list`` and ``help``) + - ``Commands.invalidCommandAlias``, ``Commands.commandAliasSameAsName``, ``Commands.duplicateCommandAlias``, ``Commands.aliasClashesWithCommandName``, and ``Commands.aliasClashesWithAlias`` (command alias validation) + +- Removed deprecated language keys tied to removed exception constructors: + - ``Core.missingExtension`` (``FrameworkException::forMissingExtension()``) + - ``HTTP.cannotSetCache`` (``DownloadException::forCannotSetCache()``) + - ``HTTP.disallowedAction`` + - ``HTTP.invalidSameSiteSetting`` (``HTTPException::forInvalidSameSiteSetting()``) + - ``Security.invalidSameSite`` (``SecurityException::forInvalidSameSite()``) + - ``Session.invalidSameSiteSetting`` (``SessionException::forInvalidSameSiteSetting()``) + +******* +Changes +******* + +- **Config:** Added the ``md`` key for ``Config\Mimes::$mimes`` for Markdown files. + +************ +Deprecations +************ + +- **CLI:** The ``CLI::parseCommandLine()`` method is now deprecated and will be removed in a future release. The ``CLI`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. +- **CLI:** Returning a non-integer exit code from a command is now deprecated and will trigger a deprecation notice. Command methods should return an integer exit code (e.g., ``0`` for success, non-zero for errors) to ensure proper behavior across all platforms. +- **CLI:** ``Commands::run()`` is now deprecated in favor of ``Commands::runLegacy()`` for legacy ``BaseCommand`` commands, and ``Commands::runCommand()`` for modern ``AbstractCommand`` commands. +- **CLI:** The ``$commands`` parameter of ``Commands::verifyCommand()`` and the ``$collection`` parameter of ``Commands::getCommandAlternatives()`` are no longer used. Passing a non-empty array for either will trigger a deprecation notice. +- **HTTP:** The ``CLIRequest::parseCommand()`` method is now deprecated and will be removed in a future release. The ``CLIRequest`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. +- **HTTP:** ``URI::setSilent()`` is now hard deprecated. This method was only previously marked as deprecated. It will now trigger a deprecation notice when used. + +********** +Bugs Fixed +********** + +See the repo's +`CHANGELOG.md `_ +for a complete list of bugs fixed. diff --git a/user_guide_src/source/cli/cli_commands.rst b/user_guide_src/source/cli/cli_commands.rst index ddf8521c8332..dc45419d7d97 100644 --- a/user_guide_src/source/cli/cli_commands.rst +++ b/user_guide_src/source/cli/cli_commands.rst @@ -141,13 +141,3 @@ be familiar with when creating your own commands. It also has a :doc:`Logger $value array. - :param integer $pad: The pad spaces. - - A method to calculate padding for ``$key => $value`` array output. The padding can be used to output a will formatted table in CLI. diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst new file mode 100644 index 000000000000..52e17ee5f55f --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -0,0 +1,666 @@ +##################### +Modern Spark Commands +##################### + +.. versionadded:: 4.8.0 + +Modern commands are a newer style of :doc:`spark commands `. +Instead of declaring metadata through class properties, modern commands describe +themselves through a ``#[Command]`` attribute and build their argument/option +surface inside a ``configure()`` method. The framework then parses the command +line, applies the declared defaults, validates what was passed, and finally +calls ``execute()`` with typed, validated values. + +Modern and legacy commands can coexist (for now): existing ``BaseCommand`` classes +continue to work, and the framework routes invocations to whichever command +matches the requested name, regardless of style. + +.. contents:: + :local: + :depth: 2 + +******************* +Creating a Command +******************* + +A modern command is a class that: + +- extends ``CodeIgniter\CLI\AbstractCommand``; +- declares a ``#[Command]`` attribute with a ``name``, a ``description`` and a ``group``; +- implements ``execute(array $arguments, array $options): int`` and returns an ``EXIT_*`` status code. + +A minimal example: + +.. literalinclude:: cli_modern_commands/001.php + +File Location +============= + +Same rule as the legacy style — commands must live under a directory named +**Commands** that is reachable through PSR-4 autoloading, for instance +**app/Commands/**. The framework auto-discovers them the first time the +command runner is used. + +The ``#[Command]`` Attribute +============================ + +The attribute holds the command's identity: + +- ``name`` is the token users type after ``php spark``. It must not be empty, must not contain + whitespace, and may use a colon to namespace related commands (``cache:clear``, ``make:migration``). + Leading, trailing, or consecutive colons are rejected. +- ``description`` is shown in the ``list`` output and at the top of ``help ``. +- ``group`` controls how the command is grouped in the ``list`` output. A command with an empty + ``group`` is skipped by discovery. +- ``aliases`` is an optional list of alternative names the command can also be invoked by. Each alias + follows the same naming rules as ``name`` and must differ from it. Aliases resolve to the command at + dispatch (``php spark `` and ``help `` both work), are listed as their own rows in the + ``list`` output, and are shown in an ``Aliases:`` section of ``help ``. + +The attribute itself validates these constraints at construction time. If you +misspell ``name``, you will see the error at discovery rather than at run time. +An alias that collides with an existing command name or with another command's +alias is a hard error at discovery, since the runner could not tell which command +you meant. + +.. literalinclude:: cli_modern_commands/014.php + +***************** +Command Lifecycle +***************** + +When the runner invokes a modern command, it walks through several phases in +this order: + +1. **Construction.** The ``#[Command]`` attribute is read, then your + ``configure(): void`` hook runs so you can register arguments, options, and + extra usage examples. A default ``--help``/ ``-h`` flag, ``--no-header`` + flag, and ``--no-interaction``/ ``-N`` flag are added automatically + afterwards. +2. ``isAvailable(): bool`` is called to check whether the command should execute + (see :ref:`restricting-execution`). +3. ``initialize(array &$arguments, array &$options): void`` receives the raw + arguments and options by reference. Useful when your command needs to + massage input — for instance, to unfold an alias argument into the canonical + form before anything else runs. +4. ``interact(array &$arguments, array &$options): void`` also receives the + raw arguments and options by reference. This is where you prompt the user + for missing input, set values conditionally, or abort early. This hook is + skipped when the command is non-interactive (see :ref:`non-interactive-mode`). +5. **Bind & validate.** The framework maps the raw input to the definitions + you declared in ``configure()``, applies defaults, and rejects input that + violates the definitions (missing required argument, unknown option, array + option passed without a value, and so on). +6. ``execute(array $arguments, array $options): int`` receives the bound and + validated arguments and options, and returns an exit code. + +You only have to implement ``execute()``; the other hooks are optional. + +********* +Arguments +********* + +Arguments are positional — the first token after the command name is bound to +the first declared argument, the second token to the second declared argument, +and so on. They are declared inside ``configure()`` using the +``CodeIgniter\CLI\Input\Argument`` value object: + +.. literalinclude:: cli_modern_commands/002.php + +The following rules are enforced at configuration time. Violating any of them +raises an ``InvalidArgumentDefinitionException``: + +- A required argument **must not** have a default value. +- An optional argument **must** have a default value. +- An array argument collects every remaining positional token. + Only one array argument may be declared, and it must come last. +- An array argument cannot be required (but it can have a non-empty default). +- Required arguments must all come before optional arguments. +- Argument names must match ``[A-Za-z0-9_-]+`` and the name ``extra_arguments`` is reserved. + +******* +Options +******* + +Options are name-based. They are declared with ``CodeIgniter\CLI\Input\Option``: + +.. literalinclude:: cli_modern_commands/003.php + +Options support the following modes (they can be combined where it makes +sense): + +- **Flag** — the default. The option takes no value. Presence makes the bound + value ``true``; absence leaves it ``false``. +- ``requiresValue: true`` — the option must be followed by a value when passed. +- ``acceptsValue: true`` — the option may be followed by a value, but the value is optional. +- ``isArray: true`` — the option may be passed multiple times; each value is appended to the bound array. +- ``negatable: true`` — a second long form ``--no-`` is registered automatically. + Passing ``--name`` sets the option to ``true``; passing ``--no-name`` sets it to ``false``. + +Every option may also declare a single-character ``shortcut`` (e.g., ``-f`` for ``--force``). +Shortcuts must be a single alphanumeric character and unique within the command. + +A few quirks are worth knowing: + +- ``requiresValue: true`` and ``isArray: true`` both imply ``acceptsValue: true``. +- An option that requires a value must be given a **string** default. The default is used only when the + option is not passed at all; passing the option without a value throws at validation. +- An array option must require a value. Its default must be ``null`` or a non-empty array + (``null`` is normalised to an empty array internally). +- A negatable option cannot accept a value or be an array. Its default must be a boolean. +- A negatable option's auto-generated ``--no-`` form will clash if another option is already named ``no-``. +- Option names must match ``[A-Za-z0-9_-]+`` and the name ``extra_options`` is reserved. +- The following options are reserved for the framework and registered on every command automatically: + + - ``--help`` / ``-h`` + - ``--no-header`` + - ``--no-interaction`` / ``-N`` + +Configuration-time violations raise ``InvalidOptionDefinitionException``. + +.. note:: + + The command-line parser does **not** understand bundled shortcuts or + shortcuts with a glued value: + + - ``-abc`` is read as one option named ``abc``, *not* as ``-a -b -c``. + - ``-fvalue`` is read as one option named ``fvalue``; it is not split into + shortcut ``-f`` with value ``value``. + + Pass shortcut values with ``-f=value`` or ``-f value`` instead. + +************************* +Interacting With the User +************************* + +``interact()`` is designed for commands that need to prompt, confirm, or fill +in missing input before validation runs. Its ``$arguments`` and ``$options`` +parameters are **raw** — they are the tokens the framework parsed from the +command line, *before* the values are mapped to your declared definitions. + +Because the raw input may be keyed by the long name, the shortcut, or the +negation form, two helpers make lookups alias-aware: + +- ``hasUnboundOption(string $name, ?array $options = null): bool`` +- ``getUnboundOption(string $name, ?array $options = null): array|string|null`` + +Inside ``initialize()`` and ``interact()`` pass ``$options`` explicitly — the +instance snapshot is not populated yet. From ``execute()`` (or any helper +reached from it) you can omit ``$options`` and the helpers will read from the +snapshot taken right after ``interact()`` returns and before bind and validate. + +.. literalinclude:: cli_modern_commands/004.php + +Any change you make to ``$arguments`` or ``$options`` inside ``interact()`` +carries through to bind, validate, and ``execute()``. + +.. _non-interactive-mode: + +Non-Interactive Mode +==================== + +Every modern command accepts ``--no-interaction`` / ``-N`` out of the box. +When the flag is present, or when the command is otherwise non-interactive, +the ``interact()`` hook is skipped entirely and the command proceeds straight +to bind, validate, and ``execute()``. + +Programmatically, the state is exposed through two public methods on +``AbstractCommand``: + +- ``isInteractive(): bool``: reports the current state. +- ``setInteractive(bool $interactive): static``: pins the state, overriding + both the CLI flag and TTY detection. Returns ``$this`` for chaining. + +The resolved state follows this precedence: + +1. An explicit ``setInteractive(bool)`` call wins. Useful when a command + must force a specific mode for safety. +2. Otherwise, the CLI flag ``--no-interaction`` / ``-N`` forces non-interactive state. +3. Otherwise, **STDIN** is probed: if it is not a TTY (piped input, cron, + CI, ``nohup``), the command is non-interactive. +4. Otherwise, the command is interactive. + +When the current command invokes another via ``$this->call(...)``, the +parent's non-interactive state is propagated to the sub-command +automatically. A caller that passes ``no-interaction`` (or ``N``) in the +sub-command's ``$options`` wins over that propagation. + +The propagation can be overridden with the ``$noInteractionOverride`` +parameter of ``call()``: + +- ``null`` (default): propagate the parent's state. +- ``true``: force the sub-command non-interactive regardless of the parent. +- ``false``: remove any forwarded ``--no-interaction`` / ``-N`` from the + child ``$options`` so the sub-command resolves its own state. Note: TTY + detection can still downgrade the sub-command if STDIN is not a TTY. + +.. _restricting-execution: + +***************************** +Restricting Command Execution +***************************** + +Sometimes a command should not run in a specific runtime context. For example, +development-only commands may need to be blocked in the ``production`` environment. + +You can override ``isAvailable()`` to decide at runtime whether the command may execute. +By default, this method returns ``true``. + +.. literalinclude:: cli_modern_commands/013.php + +The availability check runs before ``initialize()``, ``interact()``, argument and +option binding, validation, and ``execute()``. If the command is not available, +a ``CommandNotAvailableException`` is thrown immediately. + +.. note:: This method prevents execution of the command, but does not remove it from help output or command discovery. + +****************** +Inside execute() +****************** + +``execute()`` receives two arrays that mirror your declared definitions: + +- ``$arguments`` contains every declared argument, bound to the provided value or the declared default. +- ``$options`` contains every declared option plus the framework defaults + (``help``, ``no-header``, ``no-interaction``), bound to the provided value + or the declared default. + +Within ``execute()`` itself, reaching into ``$arguments`` / ``$options`` directly +is the simplest thing to do. The same data is also available through helpers +so you can reach it from sub-methods without having to thread the two arrays +through every signature: + +- ``getValidatedArgument(string $name)`` / ``getValidatedArguments()`` +- ``getValidatedOption(string $name)`` / ``getValidatedOptions()`` +- ``getUnboundArgument(int $index)`` / ``getUnboundArguments()`` +- ``getUnboundOption(string $name, ?array $options = null)`` / ``getUnboundOptions()`` + +The *validated* variants expose the bound values (what your definition says). +The *unbound* variants expose the raw input snapshot — useful when forwarding +the command to another command, or when your logic needs to know whether a +flag was actually passed rather than whether it resolved to a default value. + +.. literalinclude:: cli_modern_commands/005.php + +Accessors that take a name throw ``LogicException`` when the name is not declared on the command. + +*********************** +Calling Another Command +*********************** + +Inside ``execute()``, a modern command can invoke another modern command through +``$this->call()``. ``call()`` must not be used from ``configure()``, ``initialize()``, +or ``interact()`` — the current command has not been bound and validated yet at +those points, and its unbound state has not been snapshotted. + +.. literalinclude:: cli_modern_commands/006.php + +The ``$arguments`` and ``$options`` you pass are interpreted as raw input — +they go through bind and validate on the target command, just like a user +invocation. + +To forward the caller's own input through to the target command, pass +``$this->getUnboundArguments()`` and ``$this->getUnboundOptions()`` to ``call()``: + +.. literalinclude:: cli_modern_commands/008.php + +.. _modern-commands-call-silently: + +Calling Silently +================ + +When a command delegates a step to another command but wants to emit its own +consolidated message instead of letting the sub-command's output leak through, +use ``$this->callSilently()``: + +.. literalinclude:: cli_modern_commands/012.php + +The sub-command's output is suppressed and ``$noInteractionOverride`` defaults +to ``true``, since a silenced sub-command cannot meaningfully prompt. Pass an +explicit value to override. + +************** +Usage Examples +************** + +The default usage line is built automatically from the command name and the +declared argument list. You can append additional example lines by calling +``addUsage()`` inside ``configure()``: + +.. literalinclude:: cli_modern_commands/007.php + +In the ``help `` or `` --help`` output the default usage line is shown first, +followed by each ``addUsage()`` entry in the order it was added. + +********************** +Rendering an Exception +********************** + +If your command catches a ``Throwable`` and wants to produce the same +formatted output the framework uses for uncaught exceptions, call +``$this->renderThrowable($exception)``. The helper is safe to call from any +command, and it will not disturb the currently shared request. + +********************************** +Migrating From ``BaseCommand`` +********************************** + +The modern command system is a superset of the legacy ``BaseCommand`` API — the +same capabilities are there, just expressed through an attribute and explicit +definitions rather than class properties and ad-hoc lookups. + +**Identity** + +``protected $name`` + Moves to ``name:`` on the ``#[Command]`` attribute. + +``protected $description`` + Moves to ``description:`` on the ``#[Command]`` attribute. + +``protected $group`` + Moves to ``group:`` on the ``#[Command]`` attribute. An empty group skips the command at discovery. + +**Input surface (declare inside** ``configure()`` **)** + +``protected $usage`` + The default usage line is generated from the declared arguments. Register extras with ``addUsage()``. + +``protected $arguments`` + One ``addArgument(new Argument(...))`` call per argument. + +``protected $options`` + One ``addOption(new Option(...))`` call per option. A long name is required; a legacy ``-x``-style + option becomes ``new Option(name: 'something', shortcut: 'x')``. + +**Runtime** + +``run(array $params)`` + No longer the extension point — ``run()`` is ``final`` on ``AbstractCommand`` and drives the lifecycle itself. + Move the body into ``execute(array $arguments, array $options): int``, which must return an ``EXIT_*`` status. + +``$params[0]`` + Use ``$arguments['name']`` or ``$this->getValidatedArgument('name')``. + +``$params['name']`` / ``CLI::getOption('name')`` + Use ``$options['name']`` or ``$this->getValidatedOption('name')``. + Call ``$this->hasUnboundOption('name')`` when you need to know whether the flag was actually passed. + +``$this->call('other', $params)`` + Becomes ``$this->call('other', $arguments, $options)``; only from inside ``execute()``. + To forward the caller's own raw input, pass ``$this->getUnboundArguments()`` and ``$this->getUnboundOptions()``. + +``$this->showError($e)`` + Becomes ``$this->renderThrowable($e)``. + +``showHelp()`` override + Gone. The built-in ``help`` command builds the help output itself from the declared arguments, options, and usages. + +Prompting the user mid-run stays with ``CLI::prompt()``, but the idiomatic spot moves from ``run()`` +to ``interact()`` so validation can see whatever the user provides interactively. + +A typical ``BaseCommand`` implementation: + +.. literalinclude:: cli_modern_commands/009.php + +…becomes, as a modern command: + +.. literalinclude:: cli_modern_commands/010.php + +Two behavioural changes are worth calling out explicitly: + +- **Validated, not raw.** Arguments and options are parsed, defaulted, and validated before ``execute()`` runs. + If a required argument is missing or a ``requiresValue`` option was passed without a value, the framework + raises a typed exception and your command is never entered. +- **Exit codes are mandatory.** Legacy ``run()`` could return ``null``. The modern ``execute()`` must return an + integer; the framework emits a deprecation notice for any legacy command that still returns ``null``. + +******************************** +Coexistence With Legacy Commands +******************************** + +Legacy ``BaseCommand`` classes are still supported, and they are discovered +alongside modern commands. If the same name is claimed by both a legacy and a +modern command, the legacy one is invoked and a warning is printed once at +discovery time so you can rename or retire one of the two. Any aliases declared +by the shadowed modern command are dropped at discovery, so they are neither +listed nor runnable. Resolve the collision and the modern command, along with +its aliases, becomes reachable again. + +To detect the collision programmatically — for example, in a migration script +that verifies the legacy copy was removed — the ``Commands`` runner exposes two +read-only checks: + +.. literalinclude:: cli_modern_commands/011.php + +The ``help`` command understands both styles — it delegates to the legacy +``showHelp()`` method for legacy commands and renders a structured view for +modern ones. + +.. note:: + + Legacy commands remain supported while the framework's own built-in + commands are being migrated to the modern style. Once that migration is + complete, ``BaseCommand`` will start emitting deprecation notices. New + commands should be written against ``AbstractCommand`` from the start. + +*************** +AbstractCommand +*************** + +The ``AbstractCommand`` class that all modern commands must extend exposes a +number of utility methods you call from within your own command. Hooks like +``configure()``, ``initialize()``, ``interact()``, and ``execute()`` are +covered in the sections above and are not listed here. + +.. php:namespace:: CodeIgniter\CLI + +.. php:class:: AbstractCommand + + .. php:method:: getCommandRunner(): Commands + + Returns the ``Commands`` runner the command was constructed with. + Useful when you need to introspect other discovered commands (for + instance, building a custom ``list``-style command). + + .. php:method:: getName(): string + + Returns the command name declared on the ``#[Command]`` attribute. + + .. php:method:: getDescription(): string + + Returns the command description declared on the ``#[Command]`` + attribute. + + .. php:method:: getGroup(): string + + Returns the command group declared on the ``#[Command]`` attribute. + + .. php:method:: getUsages(): array + + Returns every usage line registered for the command — the default + line built from the argument list, followed by each ``addUsage()`` + entry in declaration order. + + .. php:method:: getArgumentsDefinition(): array + + Returns the ``Argument`` value objects registered on this command, + keyed by argument name and ordered by declaration. + + .. php:method:: getOptionsDefinition(): array + + Returns the ``Option`` value objects registered on this command, + keyed by option name. + + .. php:method:: getShortcuts(): array + + Returns the shortcut-to-option-name map (for example + ``['f' => 'force']``). Empty when no shortcut is declared. + + .. php:method:: getNegations(): array + + Returns the negation-to-option-name map (for example + ``['no-force' => 'force']``). Empty when no negatable option is + declared. + + .. php:method:: addUsage(string $usage): static + + :param string $usage: An extra usage example line. + + Adds a usage example to the ``help `` output. The default + usage line derived from the argument list is always shown first. + + .. php:method:: addArgument(Argument $argument): static + + :param Argument $argument: The argument definition to register. + + Registers a positional argument. Call from ``configure()``. + + .. php:method:: addOption(Option $option): static + + :param Option $option: The option definition to register. + + Registers an option. Call from ``configure()``. + + .. php:method:: renderThrowable(Throwable $e): void + + :param Throwable $e: The throwable to render. + + Produces the same formatted output the framework uses for uncaught + exceptions. Safe to call from any command. + + .. php:method:: hasArgument(string $name): bool + + :param string $name: The argument name to look up. + + Returns ``true`` if an argument with that name is declared on the + command. + + .. php:method:: hasOption(string $name): bool + + :param string $name: The option name to look up. + + Returns ``true`` if an option with that name is declared on the + command. + + .. php:method:: hasShortcut(string $shortcut): bool + + :param string $shortcut: The shortcut character to look up. + + Returns ``true`` if the shortcut is claimed by one of the declared + options. + + .. php:method:: hasNegation(string $name): bool + + :param string $name: The negation name (for example ``no-force``) to look up. + + Returns ``true`` if the negation is registered by one of the + declared options. + + .. php:method:: isInteractive(): bool + + Reports whether the command will prompt the user. See + :ref:`non-interactive-mode` for the resolution order. + + .. php:method:: setInteractive(bool $interactive): static + + :param bool $interactive: The state to pin. + :returns: The current command instance for chaining. + + Overrides both the ``--no-interaction`` / ``-N`` flag and TTY + detection for this command instance. Typically called from + ``initialize()`` or by an outer caller. + + .. php:method:: run(array $arguments, array $options): int + + :param array $arguments: The raw positional arguments parsed from the command line. + :param array $options: The raw option map parsed from the command line. + :returns: The exit code returned by ``execute()``. + + **Final.** Walks the command through ``initialize()``, ``interact()``, + bind, validate, and finally ``execute()``. The framework calls this + on your behalf — you rarely invoke it directly, but you can when + driving a command manually (for instance, from a test). + + .. php:method:: call(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null]): int + + :param string $command: The name of the modern command to call. + :param array $arguments: Positional arguments to forward. + :param array $options: Options to forward, keyed by long name, shortcut, or negation. + :param bool|null $noInteractionOverride: Override the sub-command's interactive state. + ``null`` propagates the parent's state (default); + ``true`` forces non-interactive; ``false`` removes + any forwarded ``--no-interaction`` from ``$options``. + :returns: The exit code returned by the called command. + + Invokes another modern command. The arguments and options go through + bind and validate on the target command, just like a user invocation. + + .. php:method:: callSilently(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true]): int + + :param string $command: The name of the modern command to call. + :param array $arguments: Positional arguments to forward. + :param array $options: Options to forward, keyed by long name, shortcut, or negation. + :param bool|null $noInteractionOverride: See :php:meth:`call`. Defaults to ``true``. + :returns: The exit code returned by the called command. + + Like :php:meth:`call`, but suppresses the sub-command's output. + + .. php:method:: getUnboundArguments(): array + + Returns the raw, parsed positional arguments as passed to the + command. + + .. php:method:: getUnboundArgument(int $index): string + + :param int $index: The zero-based index of the argument to read. + + Returns a single raw positional argument. Throws + ``LogicException`` when the index does not exist. + + .. php:method:: getUnboundOptions(): array + + Returns the raw, parsed option map, keyed by long name, shortcut, + or negation. + + .. php:method:: getUnboundOption(string $name[, array|null $options = null]): array|string|null + + :param string $name: The declared option name to look up. + :param array|null $options: Raw option map to read from. Required inside ``initialize()`` and ``interact()``, optional from ``execute()`` onwards. + + Returns the raw value the option was given, resolving its shortcut + and negation. Returns ``null`` when the option was not provided — + callers can use the ``??`` operator to supply a fallback, or + :php:meth:`hasUnboundOption` to disambiguate presence from a ``null`` + value. Throws ``LogicException`` when the option is not declared on + this command. + + .. php:method:: hasUnboundOption(string $name[, array|null $options = null]): bool + + :param string $name: The declared option name to look up. + :param array|null $options: Raw option map to read from. Required inside ``initialize()`` and ``interact()``, optional from ``execute()`` onwards. + + Returns ``true`` if the option was provided under its long name, + shortcut, or negation. Throws ``LogicException`` when the option is + not declared on this command. + + .. php:method:: getValidatedArguments(): array + + Returns the bound and validated arguments, keyed by declared name. + + .. php:method:: getValidatedArgument(string $name): array|string + + :param string $name: The declared argument name to read. + + Returns the bound and validated value for a single argument. Throws + ``LogicException`` when the argument is not declared on this command. + + .. php:method:: getValidatedOptions(): array + + Returns the bound and validated options, keyed by declared name. + + .. php:method:: getValidatedOption(string $name): bool|array|string|null + + :param string $name: The declared option name to read. + + Returns the bound and validated value for a single option. Throws + ``LogicException`` when the option is not declared on this command. diff --git a/user_guide_src/source/cli/cli_modern_commands/001.php b/user_guide_src/source/cli/cli_modern_commands/001.php new file mode 100644 index 000000000000..0033a19b122b --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/001.php @@ -0,0 +1,18 @@ +addArgument(new Argument( + name: 'name', + description: 'Who to greet.', + required: true, + )) + // Optional — a default is mandatory. + ->addArgument(new Argument( + name: 'salutation', + description: 'Optional salutation.', + default: 'Hello', + )) + // Array — collects every remaining token. Must be declared last. + ->addArgument(new Argument( + name: 'extras', + description: 'Any extra tokens.', + isArray: true, + )); + } + + protected function execute(array $arguments, array $options): int + { + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/003.php b/user_guide_src/source/cli/cli_modern_commands/003.php new file mode 100644 index 000000000000..2e3bb3bfcee3 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/003.php @@ -0,0 +1,58 @@ +addOption(new Option( + name: 'verbose', + shortcut: 'v', + description: 'Enable verbose output.', + )) + // Value-required. --destination=path or -d path. A string default is mandatory. + ->addOption(new Option( + name: 'destination', + shortcut: 'd', + description: 'Destination folder.', + requiresValue: true, + default: 'public', + )) + // Value-optional. Both `--driver` and `--driver=redis` are accepted. + ->addOption(new Option( + name: 'driver', + description: 'Optional driver override.', + acceptsValue: true, + )) + // Array. `--tag=a --tag=b` collects to ['a', 'b']. Array options must require a value. + ->addOption(new Option( + name: 'tag', + shortcut: 't', + description: 'Tag to publish (may be repeated).', + requiresValue: true, + isArray: true, + )) + // Negatable. Both --clean and --no-clean are registered automatically. + ->addOption(new Option( + name: 'clean', + description: 'Clean the destination first.', + negatable: true, + default: true, + )); + } + + protected function execute(array $arguments, array $options): int + { + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/004.php b/user_guide_src/source/cli/cli_modern_commands/004.php new file mode 100644 index 000000000000..5c9340d07f41 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/004.php @@ -0,0 +1,51 @@ +addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Skip the confirmation prompt.', + )); + } + + protected function interact(array &$arguments, array &$options): void + { + // hasUnboundOption() resolves --force, -f, and --no-force in one call, + // even though $options here is still the raw parsed input. + if ($this->hasUnboundOption('force', $options)) { + return; + } + + if (CLI::prompt('Delete the logs?', ['n', 'y']) === 'n') { + return; + } + + // Mutations made here flow through to bind(), validate(), and execute(). + // For a flag option, writing `null` models "the flag was passed". + $options['force'] = null; + } + + protected function execute(array $arguments, array $options): int + { + if ($this->getValidatedOption('force') === false) { + CLI::error('Aborted.'); + + return EXIT_ERROR; + } + + // ... actually delete the logs ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/005.php b/user_guide_src/source/cli/cli_modern_commands/005.php new file mode 100644 index 000000000000..9a2ca303b8ff --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/005.php @@ -0,0 +1,38 @@ +addArgument(new Argument(name: 'name', required: true)) + ->addOption(new Option(name: 'loud', negatable: true, default: false)); + } + + protected function execute(array $arguments, array $options): int + { + // Directly from the parameters: + $name = $arguments['name']; + + // Or via the validated accessors — throws LogicException if the name + // is not declared on this command: + $name = $this->getValidatedArgument('name'); + $loud = $this->getValidatedOption('loud'); + + // Need to know whether --loud was actually passed, not just whether it + // resolved to its declared default? Use the unbound accessors: + $loudWasPassed = $this->hasUnboundOption('loud'); + + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/006.php b/user_guide_src/source/cli/cli_modern_commands/006.php new file mode 100644 index 000000000000..760f1d2d198e --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/006.php @@ -0,0 +1,9 @@ +call('cache:clear', arguments: ['file']); + +// ...and/or with options. Use `null` for a flag's value to model "the flag was passed". +$this->call('logs:clear', options: ['force' => null]); diff --git a/user_guide_src/source/cli/cli_modern_commands/007.php b/user_guide_src/source/cli/cli_modern_commands/007.php new file mode 100644 index 000000000000..d9d959918296 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/007.php @@ -0,0 +1,28 @@ +addArgument(new Argument(name: 'name', required: true)) + // The default usage line is always generated from the declared + // arguments; these extra lines are appended after it. + ->addUsage('app:greet Alice') + ->addUsage('app:greet "Bob the Builder"'); + } + + protected function execute(array $arguments, array $options): int + { + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/008.php b/user_guide_src/source/cli/cli_modern_commands/008.php new file mode 100644 index 000000000000..43213a3d2690 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/008.php @@ -0,0 +1,6 @@ +call('cache:clear', $this->getUnboundArguments(), $this->getUnboundOptions()); diff --git a/user_guide_src/source/cli/cli_modern_commands/009.php b/user_guide_src/source/cli/cli_modern_commands/009.php new file mode 100644 index 000000000000..c90611c524d3 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/009.php @@ -0,0 +1,49 @@ +] [options]'; + protected $arguments = ['target' => 'What to publish.']; + protected $options = [ + '--force' => 'Overwrite existing output.', + '--dry-run' => 'Print the plan without writing anything.', + ]; + + public function run(array $params) + { + try { + $target = $params[0] ?? CLI::prompt('What should I publish?', null, 'required'); + + $this->publish($target); + + return EXIT_SUCCESS; + } catch (Throwable $e) { + $this->showError($e); + + return EXIT_ERROR; + } + } + + private function publish(string $target): void + { + // Option values come from CLI's global state, so the sub-method still + // has to thread the positional $target through but can reach the + // named options without extra parameters. + $force = CLI::getOption('force') !== null; + $dryRun = CLI::getOption('dry-run') !== null; + + // ... + unset($force, $dryRun); + + CLI::write(sprintf('publishing %s', $target), 'green'); + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/010.php b/user_guide_src/source/cli/cli_modern_commands/010.php new file mode 100644 index 000000000000..3f2c63764d6a --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/010.php @@ -0,0 +1,55 @@ +addArgument(new Argument(name: 'target', description: 'What to publish.', required: true)) + ->addOption(new Option(name: 'force', description: 'Overwrite existing output.')) + ->addOption(new Option(name: 'dry-run', description: 'Print the plan without writing anything.')); + } + + protected function interact(array &$arguments, array &$options): void + { + if ($arguments === []) { + $arguments[] = CLI::prompt('What should I publish?', null, 'required'); + } + } + + protected function execute(array $arguments, array $options): int + { + try { + $this->publish($arguments['target']); + + return EXIT_SUCCESS; + } catch (Throwable $e) { + $this->renderThrowable($e); + + return EXIT_ERROR; + } + } + + private function publish(string $target): void + { + // Unlike the legacy version, the sub-method can reach the validated + // options through the helpers without threading $options through. + $force = $this->getValidatedOption('force') === true; + $dryRun = $this->getValidatedOption('dry-run') === true; + + // ... + unset($force, $dryRun); + + CLI::write(sprintf('publishing %s', $target), 'green'); + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/011.php b/user_guide_src/source/cli/cli_modern_commands/011.php new file mode 100644 index 000000000000..fc355d177642 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/011.php @@ -0,0 +1,10 @@ +hasLegacyCommand('foo') && $commands->hasModernCommand('foo')) { + // Both registries claim the name; the legacy version will run. +} diff --git a/user_guide_src/source/cli/cli_modern_commands/012.php b/user_guide_src/source/cli/cli_modern_commands/012.php new file mode 100644 index 000000000000..0963ccccc08c --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/012.php @@ -0,0 +1,12 @@ +callSilently('cache:clear'); + +if ($exitCode === EXIT_SUCCESS) { + CLI::write('Cache cleared as part of deploy step.', 'green'); +} diff --git a/user_guide_src/source/cli/cli_modern_commands/013.php b/user_guide_src/source/cli/cli_modern_commands/013.php new file mode 100644 index 000000000000..a7935131e407 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/013.php @@ -0,0 +1,23 @@ +getOption('foo'); // bar -echo $request->getOption('notthere'); // null +// command line: php index.php users 21 profile --foo bar --option baz --option qux + +echo $request->getOption('foo'); // bar +echo $request->getOption('not-there'); // null +echo $request->getOption('option'); // qux diff --git a/user_guide_src/source/cli/cli_request/007.php b/user_guide_src/source/cli/cli_request/007.php new file mode 100644 index 000000000000..7c015ac9926c --- /dev/null +++ b/user_guide_src/source/cli/cli_request/007.php @@ -0,0 +1,7 @@ +getRawOption('foo'); // bar +echo $request->getRawOption('not-there'); // null +var_dump($request->getRawOption('option')); // array(2) { [0]=> string(3) "baz" [1]=> string(3) "qux" } diff --git a/user_guide_src/source/cli/index.rst b/user_guide_src/source/cli/index.rst index cbbb572243e6..d6cf47d998e8 100644 --- a/user_guide_src/source/cli/index.rst +++ b/user_guide_src/source/cli/index.rst @@ -11,6 +11,7 @@ CodeIgniter 4 can also be used with command line programs. cli_controllers spark_commands cli_commands + cli_modern_commands cli_generators cli_library cli_signals diff --git a/user_guide_src/source/database/configuration.rst b/user_guide_src/source/database/configuration.rst index 3d523bcbf3d9..f62fe911757b 100644 --- a/user_guide_src/source/database/configuration.rst +++ b/user_guide_src/source/database/configuration.rst @@ -140,58 +140,66 @@ and decode it in the constructor in the Config class: Description of Values ********************* -================ =========================================================================================================== - Config Name Description -================ =========================================================================================================== -**DSN** The DSN connect string (an all-in-one configuration sequence). -**hostname** The hostname of your database server. Often this is 'localhost'. -**username** The username used to connect to the database. (``SQLite3`` does not use this.) -**password** The password used to connect to the database. (``SQLite3`` does not use this.) -**database** The name of the database you want to connect to. - - .. note:: CodeIgniter doesn't support dots (``.``) in the table and column names. - Since v4.5.0, database names with dots are supported. -**DBDriver** The database driver name. The case must match the driver name. - You can set a fully qualified classname to use your custom driver. - Supported drivers: ``MySQLi``, ``Postgre``, ``SQLite3``, ``SQLSRV``, and ``OCI8``. -**DBPrefix** An optional table prefix which will be added to the table name when running - :doc:`Query Builder ` queries. This permits multiple CodeIgniter - installations to share one database. -**pConnect** true/false (boolean) - Whether to use a persistent connection. -**DBDebug** true/false (boolean) - Whether to throw exceptions when database errors occur. -**charset** The character set used in communicating with the database. -**DBCollat** (``MySQLi`` only) The character collation used in communicating with the database. -**swapPre** A default table prefix that should be swapped with ``DBPrefix``. This is useful for distributed - applications where you might run manually written queries, and need the prefix to still be - customizable by the end user. -**schema** (``Postgre`` and ``SQLSRV`` only) The database schema, default value varies by driver. -**encrypt** (``MySQLi`` and ``SQLSRV`` only) Whether to use an encrypted connection. - See :ref:`MySQLi encrypt ` for ``MySQLi`` settings. - ``SQLSRV`` driver accepts true/false. -**compress** (``MySQLi`` only) Whether to use client compression. -**strictOn** (``MySQLi`` only) true/false (boolean) - Whether to force "Strict Mode" connections, good for ensuring - strict SQL while developing an application. -**port** The database port number - Empty string ``''`` for default port (or dynamic port with ``SQLSRV``). -**foreignKeys** (``SQLite3`` only) true/false (boolean) - Whether to enable Foreign Key constraint. - - .. important:: SQLite3 Foreign Key constraint is disabled by default. - See `SQLite documentation `_. - To enforce Foreign Key constraint, set this config item to true. -**busyTimeout** (``SQLite3`` only) milliseconds (int) - Sleeps for a specified amount of time when a table is locked. -**synchronous** (``SQLite3`` only) flag (int) - How strict SQLite will be at flushing to disk during transactions. - Use `null` to stay with the default setting. This can be used since v4.6.0. -**numberNative** (``MySQLi`` only) true/false (boolean) - Whether to enable MYSQLI_OPT_INT_AND_FLOAT_NATIVE. -**foundRows** (``MySQLi`` only) true/false (boolean) - Whether to enable MYSQLI_CLIENT_FOUND_ROWS. -**dateFormat** The default date/time formats as PHP's `DateTime format`_. - * ``date`` - date format - * ``datetime`` - date and time format - * ``datetime-ms`` - date and time with millisecond format - * ``datetime-us`` - date and time with microsecond format - * ``time`` - time format - This can be used since v4.5.0, and you can get the value, e.g., ``$db->dateFormat['datetime']``. - Currently, the database drivers do not use these values directly, - but :ref:`Model ` uses them. -================ =========================================================================================================== +=========================== ===================================================================================================== + Config Name Description +=========================== ===================================================================================================== +**DSN** The DSN connect string (an all-in-one configuration sequence). +**hostname** The hostname of your database server. Often this is 'localhost'. +**username** The username used to connect to the database. (``SQLite3`` does not use this.) +**password** The password used to connect to the database. (``SQLite3`` does not use this.) +**database** The name of the database you want to connect to. + + .. note:: CodeIgniter doesn't support dots (``.``) in the table and column names. + Since v4.5.0, database names with dots are supported. +**DBDriver** The database driver name. The case must match the driver name. + You can set a fully qualified classname to use your custom driver. + Supported drivers: ``MySQLi``, ``Postgre``, ``SQLite3``, ``SQLSRV``, and ``OCI8``. +**DBPrefix** An optional table prefix which will be added to the table name when running + :doc:`Query Builder ` queries. This permits multiple CodeIgniter + installations to share one database. +**pConnect** true/false (boolean) - Whether to use a persistent connection. +**DBDebug** true/false (boolean) - Whether to throw exceptions when database errors occur. +**charset** The character set used in communicating with the database. +**DBCollat** (``MySQLi`` only) The character collation used in communicating with the database. +**swapPre** A default table prefix that should be swapped with ``DBPrefix``. This is useful for distributed + applications where you might run manually written queries, and need the prefix to still be + customizable by the end user. +**schema** (``Postgre`` and ``SQLSRV`` only) The database schema, default value varies by driver. +**encrypt** (``MySQLi`` and ``SQLSRV`` only) Whether to use an encrypted connection. + See :ref:`MySQLi encrypt ` for ``MySQLi`` settings. + ``SQLSRV`` driver accepts true/false. +**trustServerCertificate** (``SQLSRV`` only) true/false (boolean) - Whether to trust the server certificate + without validating it against a trusted certificate authority. +**compress** (``MySQLi`` only) Whether to use client compression. +**strictOn** (``MySQLi`` only) true/false (boolean) - Whether to force "Strict Mode" connections, good for ensuring + strict SQL while developing an application. +**port** The database port number - Empty string ``''`` for default port (or dynamic port with ``SQLSRV``). +**foreignKeys** (``SQLite3`` only) true/false (boolean) - Whether to enable Foreign Key constraint. + + .. important:: SQLite3 Foreign Key constraint is disabled by default. + See `SQLite documentation `_. + To enforce Foreign Key constraint, set this config item to true. +**busyTimeout** (``SQLite3`` only) milliseconds (int) - Sleeps for a specified amount of time when a table is locked. +**synchronous** (``SQLite3`` only) flag (int) - How strict SQLite will be at flushing to disk during transactions. + Use `null` to stay with the default setting. This can be used since v4.6.0. +**numberNative** (``MySQLi`` only) true/false (boolean) - Whether to enable MYSQLI_OPT_INT_AND_FLOAT_NATIVE. +**foundRows** (``MySQLi`` only) true/false (boolean) - Whether to enable MYSQLI_CLIENT_FOUND_ROWS. +**dateFormat** The default date/time formats as PHP's `DateTime format`_. + * ``date`` - date format + * ``datetime`` - date and time format + * ``datetime-ms`` - date and time with millisecond format + * ``datetime-us`` - date and time with microsecond format + * ``time`` - time format + This can be used since v4.5.0, and you can get the value, e.g., ``$db->dateFormat['datetime']``. + Currently, the database drivers do not use these values directly, + but :ref:`Model ` uses them. +**timezone** (``MySQLi``, ``Postgre``, and ``OCI8`` only) The database session timezone. + * ``false`` - Don't set session timezone (default, backward compatible) + * ``true`` - Automatically sync with ``App::$appTimezone`` + * ``string`` - Specific timezone offset (e.g., ``'+05:30'``) or named timezone (e.g., ``'America/New_York'``) + Named timezones are automatically converted to offsets for database compatibility. + See :ref:`database-config-timezone` for details. +=========================== ===================================================================================================== .. _DateTime format: https://www.php.net/manual/en/datetime.format.php @@ -229,3 +237,21 @@ MySQLi driver accepts an array with the following options: * ``ssl_capath`` - Path to a directory containing trusted CA certificates in PEM format * ``ssl_cipher`` - List of *allowed* ciphers to be used for the encryption, separated by colons (``:``) * ``ssl_verify`` - true/false (boolean) - Whether to verify the server certificate or not + +.. _database-config-timezone: + +timezone +-------- + +.. versionadded:: 4.8.0 + +Synchronizes the database session timezone with your application timezone to ensure consistent +timestamps between model operations and database functions like ``NOW()``. + +.. note:: Modern database environments usually have UTC set by default, so this option may not be needed + in most cases. + +Accepts ``false`` (default, don't set), ``true`` (auto-sync with ``App::$appTimezone``), +or a timezone string (e.g., ``'+05:30'`` or ``'America/New_York'``). + +Named timezones are automatically converted to offsets for compatibility. diff --git a/user_guide_src/source/database/configuration/006.php b/user_guide_src/source/database/configuration/006.php index ff63399207ad..6991cd2fa711 100644 --- a/user_guide_src/source/database/configuration/006.php +++ b/user_guide_src/source/database/configuration/006.php @@ -23,7 +23,7 @@ class Database extends Config 'swapPre' => '', 'compress' => false, 'encrypt' => false, - 'strictOn' => false, + 'strictOn' => true, 'failover' => [], ]; diff --git a/user_guide_src/source/database/queries.rst b/user_guide_src/source/database/queries.rst index 4a07b4ba8295..915f4f8bad60 100644 --- a/user_guide_src/source/database/queries.rst +++ b/user_guide_src/source/database/queries.rst @@ -138,6 +138,10 @@ don't have to: .. literalinclude:: queries/009.php +.. versionadded:: 4.8.0 + ``$db->escape()`` accepts PHP ``BackedEnum`` values and escapes their backing + values. + 2. $db->escapeString() ====================== @@ -188,6 +192,13 @@ The secondary benefit of using binds is that the values are automatically escaped producing safer queries. You don't have to remember to manually escape data - the engine does it automatically for you. +.. versionadded:: 4.8.0 + Query bindings, prepared query values, and Query Builder bound values accept + PHP ``BackedEnum`` values. CodeIgniter uses the enum backing value before + escaping or passing the value to the database driver. + +.. literalinclude:: queries/032.php + Named Bindings ============== @@ -203,15 +214,65 @@ placeholders in the query: Handling Errors *************** +.. note:: It is strongly recommended to keep ``DBDebug`` set to ``true`` + (the default). This ensures all query failures surface immediately as + exceptions, preventing silent data corruption. Setting it to ``false`` + is considered legacy behaviour and may be deprecated in a future version. + +DBDebug Enabled (Recommended) +============================= + +When :ref:`DBDebug ` is ``true`` +(the default), any query failure throws a ``DatabaseException`` or one of +its subclasses, which you can catch and handle: + +.. literalinclude:: queries/030.php + +.. _database-unique-constraint-violation: + +UniqueConstraintViolationException +---------------------------------- + +.. versionadded:: 4.8.0 + +``UniqueConstraintViolationException`` extends ``DatabaseException`` and is +thrown specifically when a query or prepared query execution fails due to a +duplicate key or unique constraint violation. Catching it separately allows you +to handle this case without inspecting raw driver-specific error codes. + +DBDebug Disabled +================ + +When ``DBDebug`` is ``false``, query and prepared query execution failures +return ``false`` instead of throwing. Two methods are available to inspect what +went wrong. + $db->error() -============ +------------ -If you need to get the last error that has occurred, the ``error()`` method -will return an array containing its code and message. Here's a quick -example: +The ``error()`` method returns an array with ``code`` and ``message`` keys +describing the last error. Error codes are driver-specific (an **int** for +MySQLi, SQLite3, and OCI8; a SQLSTATE **string** for Postgre and SQLSRV): .. literalinclude:: queries/015.php +.. _database-get-last-exception: + +$db->getLastException() +----------------------- + +.. versionadded:: 4.8.0 + +``getLastException()`` returns the typed exception that would have been thrown +had ``DBDebug`` been ``true``. This is the recommended way to distinguish +between failure types (e.g., a unique constraint violation vs. another database +error) without enabling ``DBDebug``: + +.. literalinclude:: queries/031.php + +.. note:: ``getLastException()`` is reset to ``null`` at the start of every + query or prepared query execution. Inspect it immediately after the failed + operation. **************** Prepared Queries @@ -258,6 +319,10 @@ query: .. literalinclude:: queries/019.php +.. versionadded:: 4.8.0 + Prepared query values accept PHP ``BackedEnum`` values and pass their + backing values to the database driver. + For queries of type "write" it returns true or false, indicating the success or failure of the query. For queries of type "read" it returns a standard :doc:`result set `. diff --git a/user_guide_src/source/database/queries/030.php b/user_guide_src/source/database/queries/030.php new file mode 100644 index 000000000000..a4fea00892f0 --- /dev/null +++ b/user_guide_src/source/database/queries/030.php @@ -0,0 +1,12 @@ +table('users')->insert(['email' => 'duplicate@example.com']); +} catch (UniqueConstraintViolationException $e) { + // Duplicate key — handle gracefully +} catch (DatabaseException $e) { + // Other database error +} diff --git a/user_guide_src/source/database/queries/031.php b/user_guide_src/source/database/queries/031.php new file mode 100644 index 000000000000..4dafb880cd5f --- /dev/null +++ b/user_guide_src/source/database/queries/031.php @@ -0,0 +1,9 @@ +table('users')->insert(['email' => 'duplicate@example.com']); + +if (! $inserted && $db->getLastException() instanceof UniqueConstraintViolationException) { + // Handle duplicate key violation +} diff --git a/user_guide_src/source/database/queries/032.php b/user_guide_src/source/database/queries/032.php new file mode 100644 index 000000000000..cdc60639869c --- /dev/null +++ b/user_guide_src/source/database/queries/032.php @@ -0,0 +1,8 @@ +table('users')->where('status', \UserStatus::Active)->get(); + +$db->query( + 'SELECT * FROM users WHERE status = ?', + [\UserStatus::Active], +); diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 480b8c18d92e..513dccbb48cb 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -110,6 +110,31 @@ The key thing to notice in the above example is that the second query did not utilize ``limit(10, 20)`` but the generated SQL query has ``LIMIT 20, 10``. The reason for this outcome is because the parameter in the first query is set to ``false``, ``limit(10, 20)`` remained in the second query. +.. _query-builder-explain: + +$builder->explain() +------------------- + +.. versionadded:: 4.8.0 + +Runs an execution-plan query for the current Query Builder ``SELECT`` query: + +.. literalinclude:: query_builder/129.php + +This method returns a database result object. The result columns are driver-specific +because each database reports execution plans in its own format. +You can read the result with ``getResultArray()`` or ``getResultObject()``, the +same as any other query result. +If test mode is enabled, it returns the compiled execution-plan SQL string. +If the query fails and ``DBDebug`` is ``false``, it returns ``false``. + +The method resets the current Query Builder state by default. If you need to keep +the current Query Builder state, you can pass ``false`` as the first parameter. + +.. note:: This method is currently supported by MySQLi, Postgre, and SQLite3. + SQLite3 uses ``EXPLAIN QUERY PLAN``. SQLSRV and OCI8 are not supported by + this method. + $builder->getWhere() -------------------- @@ -297,6 +322,13 @@ methods: .. note:: ``$builder->where()`` accepts an optional third parameter. If you set it to ``false``, CodeIgniter will not try to protect your field or table names. +.. versionadded:: 4.8.0 + Query Builder methods that bind and escape field values or value lists, + such as ``where()``, ``whereIn()``, ``having()``, ``set()``, ``insert()``, + and ``update()``, accept PHP ``BackedEnum`` instances and use their backing + values. Raw SQL values, such as values passed with escaping disabled, are + not converted. + 1. Simple key/value method ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -365,6 +397,108 @@ instances are joined by **OR**: .. literalinclude:: query_builder/029.php +.. _query-builder-where-column: + +$builder->whereColumn() +----------------------- + +.. versionadded:: 4.8.0 + +Compares one column to another column. If the first parameter does not end with +a supported operator, ``=`` is used: + +.. literalinclude:: query_builder/123.php + +You can include a supported operator at the end of the first parameter in order +to control the comparison. Supported operators are ``=``, ``!=``, ``<>``, ``<``, +``>``, ``<=``, and ``>=``. If none of these operators is detected at the end of the first +parameter, ``=`` is used. Empty +column names throw an ``InvalidArgumentException``. + +Column names are protected by default, unless the ``$escape`` parameter is +``false``. + +.. warning:: Do not pass user-supplied data as column names. Values should use + ``where()`` or another value-binding method instead. + +$builder->orWhereColumn() +------------------------- + +This method is identical to ``whereColumn()``, except that multiple instances +are joined by **OR**. + +.. _query-builder-where-between: + +$builder->whereBetween() +------------------------ + +.. versionadded:: 4.8.0 + +Generates a **WHERE** field ``BETWEEN`` minimum and maximum value SQL query. +``BETWEEN`` includes both values: + +.. literalinclude:: query_builder/126.php + +The range array must contain exactly two values: the lower and upper bounds. +These values are bound and escaped automatically. The ``$escape`` parameter +controls value escaping and identifier protection. + +.. warning:: Do not pass user-supplied data as field names. If you need a more + complex SQL expression, use ``where()`` with :ref:`RawSql ` + and escape values manually. + +$builder->orWhereBetween() +-------------------------- + +This method is identical to ``whereBetween()``, except that multiple instances +are joined by **OR**. + +$builder->whereNotBetween() +--------------------------- + +This method is identical to ``whereBetween()``, except that it generates +``NOT BETWEEN``. + +$builder->orWhereNotBetween() +----------------------------- + +This method is identical to ``whereNotBetween()``, except that multiple +instances are joined by **OR**. + +.. _query-builder-where-exists: + +$builder->whereExists() +----------------------- + +.. versionadded:: 4.8.0 + +Generates a ``WHERE EXISTS`` subquery. This method accepts either a Closure or +a ``BaseBuilder`` instance: + +.. literalinclude:: query_builder/125.php + +.. warning:: Raw SQL strings are not accepted. If you need to write the + ``EXISTS`` clause yourself, use ``where()`` with a manually escaped + condition. See :ref:`query-builder-where-rawsql`. + +$builder->orWhereExists() +------------------------- + +This method is identical to ``whereExists()``, except that multiple instances +are joined by **OR**. + +$builder->whereNotExists() +-------------------------- + +This method is identical to ``whereExists()``, except that it generates a +``WHERE NOT EXISTS`` subquery. + +$builder->orWhereNotExists() +---------------------------- + +This method is identical to ``whereNotExists()``, except that multiple +instances are joined by **OR**. + $builder->whereIn() ------------------- @@ -467,10 +601,28 @@ searches. .. warning:: When you use ``RawSql``, you MUST escape the values and protect the identifiers manually. Failure to do so could result in SQL injections. +.. _query-builder-like-any: + +$builder->likeAny() +------------------- + +.. versionadded:: 4.8.0 + +This method generates a grouped set of **LIKE** clauses joined by **OR**. +Use it when you want to search for one value across multiple fields: + +.. literalinclude:: query_builder/130.php + +Unlike the associative array form of ``like()``, the field list must be a +non-empty list of field names. The same match value is used for every field. +The field list may also contain ``RawSql`` instances; see :ref:`query-builder-like-rawsql`. +Use ``orLikeAny()`` when the grouped search should be separated from previous +conditions with **OR**. + $builder->orLike() ------------------ -This method is identical to the one above, except that multiple +This method is identical to ``like()``, except that multiple instances are joined by **OR**: .. literalinclude:: query_builder/042.php @@ -532,6 +684,44 @@ $builder->orHaving() Identical to ``having()``, only separates multiple clauses with **OR**. +.. _query-builder-having-between: + +$builder->havingBetween() +------------------------- + +.. versionadded:: 4.8.0 + +Generates a **HAVING** field ``BETWEEN`` minimum and maximum value SQL query. +``BETWEEN`` includes both values: + +.. literalinclude:: query_builder/127.php + +The range array must contain exactly two values: the lower and upper bounds. +These values are bound and escaped automatically. The ``$escape`` parameter +controls value escaping and identifier protection. + +.. warning:: Do not pass user-supplied data as field names. If you need a more + complex SQL expression, use ``having()`` with :ref:`RawSql ` + and escape values manually. + +$builder->orHavingBetween() +--------------------------- + +This method is identical to ``havingBetween()``, except that multiple instances +are joined by **OR**. + +$builder->havingNotBetween() +---------------------------- + +This method is identical to ``havingBetween()``, except that it generates +``NOT BETWEEN``. + +$builder->orHavingNotBetween() +------------------------------ + +This method is identical to ``havingNotBetween()``, except that multiple +instances are joined by **OR**. + $builder->havingIn() -------------------- @@ -711,6 +901,38 @@ first parameter. .. literalinclude:: query_builder/073.php +.. _query-builder-exists: + +$builder->exists() +------------------ + +.. versionadded:: 4.8.0 + +Permits you to determine whether the current Query Builder query would return +at least one row: + +.. literalinclude:: query_builder/128.php + +This method returns ``true`` when the query would return at least one row and +``false`` when it would not. It respects any existing ``limit()`` and +``offset()`` clauses, and resets the current Query Builder state by default. If +you need to keep the current Query Builder state, you can pass ``false`` as the +first parameter. +If the existence query fails and ``DBDebug`` is ``false``, both methods return +``false``. + +$builder->doesntExist() +----------------------- + +.. versionadded:: 4.8.0 + +This method is identical to ``exists()``, except that it returns ``true`` when +the query would not return any rows. + +.. note:: These methods execute the current Query Builder query to check for + rows. To add an SQL ``EXISTS`` predicate to a query, use + :ref:`query-builder-where-exists`. + $builder->countAll() -------------------- @@ -723,6 +945,97 @@ As is in ``countAllResult()`` method, this method resets any field values that y to ``select()`` as well. If you need to keep them, you can pass ``false`` as the first parameter. +******************** +Pessimistic Locking +******************** + +.. _query-builder-shared-lock: + +Shared Lock +=========== + +$builder->sharedLock() +---------------------- + +.. versionadded:: 4.8.0 + +Adds a pessimistic read lock to a ``SELECT`` query. This is useful when rows +must be read consistently while other transactions are prevented from modifying +them until the current transaction ends. + +.. literalinclude:: query_builder/131.php + +Use this method inside a database transaction. Without an explicit transaction, +the lock is typically released when the ``SELECT`` statement finishes. If the +same transaction will update the selected rows, use ``lockForUpdate()`` instead. + +This method is supported by the **MySQLi**, **Postgre**, and **SQLSRV** +drivers. Unsupported drivers throw a ``DatabaseException``. ``sharedLock()`` is +not supported with ``union()`` or ``unionAll()``. Some databases restrict which +query shapes can be used with row locking. When CodeIgniter can detect an +unsupported combination, it throws a ``DatabaseException``. See the following +warnings for driver-specific behavior. + +.. warning:: MySQLi does not support ``sharedLock()`` with ``fromSubquery()`` + because an outer locking read on a derived table does not lock the underlying + rows as users may expect. + +.. warning:: Postgre does not support ``sharedLock()`` with ``distinct()``, + ``groupBy()``, ``having()``, or aggregate helper selections such as + ``selectCount()``. + +.. warning:: SQLSRV uses SQL Server table hints instead of a trailing ``FOR SHARE`` + clause. The hint is applied to table references in the ``FROM`` clause; + joined tables are not hinted. Its exact lock granularity depends on SQL + Server's execution plan and transaction isolation level. SQLSRV does not + support ``sharedLock()`` without a ``FROM`` table or on subqueries. + +.. _query-builder-lock-for-update: + +Lock for Update +=============== + +$builder->lockForUpdate() +------------------------- + +.. versionadded:: 4.8.0 + +Adds a pessimistic write lock to a ``SELECT`` query. This is useful when a row +must be read and then updated safely while other transactions are prevented +from modifying it first. + +.. literalinclude:: query_builder/124.php + +Use this method inside a database transaction. Without an explicit transaction, +the lock is typically released when the ``SELECT`` statement finishes. The exact +locking behavior is determined by the database server and transaction isolation +level. + +This method is supported by the **MySQLi**, **Postgre**, **OCI8**, and +**SQLSRV** drivers. Unsupported drivers throw a ``DatabaseException``. +``lockForUpdate()`` is not supported with ``union()`` or ``unionAll()``. +Some databases restrict which query shapes can be used with row locking. When +CodeIgniter can detect an unsupported combination, it throws a +``DatabaseException``. See the following warnings for driver-specific behavior. + +.. warning:: MySQLi does not support ``lockForUpdate()`` with ``fromSubquery()`` + because an outer locking read on a derived table does not lock the underlying + rows as users may expect. + +.. warning:: Postgre does not support ``lockForUpdate()`` with ``distinct()``, + ``groupBy()``, ``having()``, or aggregate helper selections such as + ``selectCount()``. + +.. warning:: SQLSRV uses SQL Server table hints instead of a trailing ``FOR UPDATE`` + clause. The hint is applied to table references in the ``FROM`` clause; + joined tables are not hinted. Its exact lock granularity depends on SQL + Server's execution plan and transaction isolation level. SQLSRV does not + support ``lockForUpdate()`` without a ``FROM`` table or on subqueries. + +.. warning:: OCI8 does not support ``lockForUpdate()`` together with + ``limit()``, ``offset()``, ``distinct()``, ``groupBy()``, ``having()``, or + aggregate helper selections such as ``selectCount()``. + .. _query-builder-union: ************* @@ -1377,6 +1690,30 @@ Class Reference Generates a platform-specific query string that counts all records in the particular table. + .. php:method:: explain([$reset = true]) + + :param bool $reset: Whether to reset values for SELECTs + :returns: The execution-plan result, SQL string when test mode is enabled, or ``false`` on failure + :rtype: ResultInterface|false|string + + Runs an execution-plan query for the current Query Builder ``SELECT`` query. + + .. php:method:: exists([$reset = true]) + + :param bool $reset: Whether to reset values for SELECTs + :returns: Whether the query would return at least one row + :rtype: bool + + Determines whether the current Query Builder query would return at least one row. + + .. php:method:: doesntExist([$reset = true]) + + :param bool $reset: Whether to reset values for SELECTs + :returns: Whether the query would not return any rows + :rtype: bool + + Determines whether the current Query Builder query would not return any rows. + .. php:method:: get([$limit = null[, $offset = null[, $reset = true]]]]) :param int $limit: The LIMIT clause @@ -1399,6 +1736,20 @@ Class Reference Same as ``get()``, but also allows the WHERE to be added directly. + .. php:method:: lockForUpdate() + + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Adds a pessimistic write lock to a ``SELECT`` query. See :ref:`query-builder-lock-for-update`. + + .. php:method:: sharedLock() + + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Adds a pessimistic read lock to a ``SELECT`` query. See :ref:`query-builder-shared-lock`. + .. php:method:: select([$select = '*'[, $escape = null]]) :param array|RawSql|string $select: The SELECT portion of a query @@ -1534,6 +1885,102 @@ Class Reference Generates the ``WHERE`` portion of the query. Separates multiple calls with ``OR``. + .. php:method:: whereColumn($first, $second[, $escape = null]) + + :param string $first: First column name, optionally ending with a supported comparison operator + :param string $second: Second column name + :param bool $escape: Whether to protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``AND``. + If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator. + Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. + + .. php:method:: orWhereColumn($first, $second[, $escape = null]) + + :param string $first: First column name, optionally ending with a supported comparison operator + :param string $second: Second column name + :param bool $escape: Whether to protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``OR``. + If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator. + Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. + + .. php:method:: whereBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: Name of field to examine + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orWhereBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: The field to search + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + + .. php:method:: whereNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: Name of field to examine + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orWhereNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: The field to search + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + + .. php:method:: whereExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE EXISTS`` subquery, joined with ``AND`` if appropriate. + + .. php:method:: orWhereExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE EXISTS`` subquery, joined with ``OR`` if appropriate. + + .. php:method:: whereNotExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE NOT EXISTS`` subquery, joined with ``AND`` if appropriate. + + .. php:method:: orWhereNotExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE NOT EXISTS`` subquery, joined with ``OR`` if appropriate. + .. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]]) :param string $key: The field to search @@ -1621,6 +2068,18 @@ Class Reference Adds a ``LIKE`` clause to a query, separating multiple calls with ``AND``. + .. php:method:: likeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]]) + + :param array $fields: List of field names or RawSql instances + :param string $match: Text portion to match + :param string $side: Which side of the expression to put the '%' wildcard on + :param bool $escape: Whether to escape values and identifiers + :param bool $insensitiveSearch: Whether to force a case-insensitive search + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Adds grouped ``LIKE`` clauses joined with ``OR``. + .. php:method:: orLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]]) :param string $field: Field name @@ -1633,6 +2092,18 @@ Class Reference Adds a ``LIKE`` clause to a query, separating multiple class with ``OR``. + .. php:method:: orLikeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]]) + + :param array $fields: List of field names or RawSql instances + :param string $match: Text portion to match + :param string $side: Which side of the expression to put the '%' wildcard on + :param bool $escape: Whether to escape values and identifiers + :param bool $insensitiveSearch: Whether to force a case-insensitive search + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Adds grouped ``LIKE`` clauses joined with ``OR``, separating the group from previous conditions with ``OR``. + .. php:method:: notLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]]) :param string $field: Field name @@ -1677,6 +2148,46 @@ Class Reference Adds a ``HAVING`` clause to a query, separating multiple calls with ``OR``. + .. php:method:: havingBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: Name of field to examine + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orHavingBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: The field to search + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + + .. php:method:: havingNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: Name of field to examine + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orHavingNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null) + + :param string $key: The field to search + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + .. php:method:: orHavingIn([$key = null[, $values = null[, $escape = null]]]) :param string $key: The field to search @@ -2040,15 +2551,41 @@ Class Reference is not a numeric field, like a ``VARCHAR``, it will likely be replaced with ``$value``. + .. php:method:: incrementMany($columns[, $value = 1]) + + .. versionadded:: 4.8.0 + + :param array $columns: A list of columns or array of column => value pairs to increment. + :param int $value: The amount to increment in the columns, if $columns is a list of columns. + :returns: ``true`` on success, ``false`` on failure + :rtype: bool + + Increments the value of multiple fields by the specified amounts. If a field + is not a numeric field, like a ``VARCHAR``, it will likely be replaced + with the amount specified for that field. + .. php:method:: decrement($column[, $value = 1]) :param string $column: The name of the column to decrement - :param int $value: The amount to decrement in the column + :param int $value: The amount to decrement in the column Decrements the value of a field by the specified amount. If the field is not a numeric field, like a ``VARCHAR``, it will likely be replaced with ``$value``. + .. php:method:: decrementMany($columns[, $value = 1]) + + .. versionadded:: 4.8.0 + + :param array $columns: A list of columns or array of column => value pairs to decrement. + :param int $value: The amount to decrement in the columns, if $columns is a list of columns. + :returns: ``true`` on success, ``false`` on failure + :rtype: bool + + Decrements the value of multiple fields by the specified amounts. If a field + is not a numeric field, like a ``VARCHAR``, it will likely be replaced + with the amount specified for that field. + .. php:method:: truncate() :returns: ``true`` on success, ``false`` on failure, string on test mode diff --git a/user_guide_src/source/database/query_builder/123.php b/user_guide_src/source/database/query_builder/123.php new file mode 100644 index 000000000000..7fa2e112f95c --- /dev/null +++ b/user_guide_src/source/database/query_builder/123.php @@ -0,0 +1,10 @@ +whereColumn('created_at', 'updated_at'); +// Produces: WHERE created_at = updated_at + +$builder->whereColumn('updated_at >', 'created_at'); +// Produces: WHERE updated_at > created_at + +$builder->whereColumn('updated_at !=', 'created_at'); +// Produces: WHERE updated_at != created_at diff --git a/user_guide_src/source/database/query_builder/124.php b/user_guide_src/source/database/query_builder/124.php new file mode 100644 index 000000000000..a9d2003c7051 --- /dev/null +++ b/user_guide_src/source/database/query_builder/124.php @@ -0,0 +1,11 @@ +transaction(static function ($db) use ($accountId): void { + $account = $db->table('accounts') + ->where('id', $accountId) + ->lockForUpdate() + ->get() + ->getRow(); + + // Use $account to update the locked row safely... +}); diff --git a/user_guide_src/source/database/query_builder/125.php b/user_guide_src/source/database/query_builder/125.php new file mode 100644 index 000000000000..5e2146eec715 --- /dev/null +++ b/user_guide_src/source/database/query_builder/125.php @@ -0,0 +1,15 @@ +whereExists(static function (BaseBuilder $builder) { + $builder->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id'); +}); +// Produces: WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id") + +// With builder directly +$subQuery = $db->table('orders')->select('1', false)->whereColumn('orders.user_id', 'users.id'); +$builder->whereNotExists($subQuery); diff --git a/user_guide_src/source/database/query_builder/126.php b/user_guide_src/source/database/query_builder/126.php new file mode 100644 index 000000000000..4345abc6949f --- /dev/null +++ b/user_guide_src/source/database/query_builder/126.php @@ -0,0 +1,6 @@ +whereBetween('created_at', ['2026-01-01', '2026-01-31']); + +// Produces: +// WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31' diff --git a/user_guide_src/source/database/query_builder/127.php b/user_guide_src/source/database/query_builder/127.php new file mode 100644 index 000000000000..02f8a13642ba --- /dev/null +++ b/user_guide_src/source/database/query_builder/127.php @@ -0,0 +1,6 @@ +select('category') + ->selectCount('id', 'total') + ->groupBy('category') + ->havingBetween('COUNT(id)', [10, 20], false); diff --git a/user_guide_src/source/database/query_builder/128.php b/user_guide_src/source/database/query_builder/128.php new file mode 100644 index 000000000000..45be139147c3 --- /dev/null +++ b/user_guide_src/source/database/query_builder/128.php @@ -0,0 +1,7 @@ +where('status', 'pending'); + +if ($builder->exists()) { + // At least one pending row exists. +} diff --git a/user_guide_src/source/database/query_builder/129.php b/user_guide_src/source/database/query_builder/129.php new file mode 100644 index 000000000000..0ddf5c3ec31f --- /dev/null +++ b/user_guide_src/source/database/query_builder/129.php @@ -0,0 +1,4 @@ +where('status', 'pending')->explain(); +$plan = $result->getResultArray(); diff --git a/user_guide_src/source/database/query_builder/130.php b/user_guide_src/source/database/query_builder/130.php new file mode 100644 index 000000000000..545a34a067b5 --- /dev/null +++ b/user_guide_src/source/database/query_builder/130.php @@ -0,0 +1,11 @@ +likeAny(['title', 'body', 'summary'], $match); + +/* + * WHERE ( + * `title` LIKE '%match%' ESCAPE '!' + * OR `body` LIKE '%match%' ESCAPE '!' + * OR `summary` LIKE '%match%' ESCAPE '!' + * ) + */ diff --git a/user_guide_src/source/database/query_builder/131.php b/user_guide_src/source/database/query_builder/131.php new file mode 100644 index 000000000000..11071bd0d02f --- /dev/null +++ b/user_guide_src/source/database/query_builder/131.php @@ -0,0 +1,11 @@ +transaction(static function ($db) use ($accountId): void { + $account = $db->table('accounts') + ->where('id', $accountId) + ->sharedLock() + ->get() + ->getRow(); + + // Use $account while preventing concurrent transactions from modifying it... +}); diff --git a/user_guide_src/source/database/transactions.rst b/user_guide_src/source/database/transactions.rst index 5c48f2806240..980541970f01 100644 --- a/user_guide_src/source/database/transactions.rst +++ b/user_guide_src/source/database/transactions.rst @@ -49,6 +49,135 @@ You can run as many queries as you want between the ``transStart()``/``transComp methods and they will all be committed or rolled back based on the success or failure of any given query. +.. _transactions-closure: + +Running Transactions with a Closure +=================================== + +.. versionadded:: 4.8.0 + +You may also run a transaction with the ``transaction()`` method. It starts a +transaction, runs the callback, commits when the callback completes +successfully, and rolls back if the callback throws an exception: + +.. literalinclude:: transactions/012.php + +The callback receives the current database connection as its only argument. +If the transaction commits successfully, ``transaction()`` returns the callback +return value. If the transaction cannot begin, or if a query failure marks the +transaction as failed without throwing an exception, ``transaction()`` rolls +back and returns ``false``. + +If transactions are disabled, ``transaction()`` does not start a transaction. +It runs the callback and returns the callback result. + +If the callback throws an exception, ``transaction()`` rolls back and rethrows +the original exception unless retry attempts remain, as described below. + +If an ``afterRollback()`` callback throws while ``transaction()`` is rolling +back, that callback exception bubbles to the caller instead of the normal +``false`` return value or the original callback exception. + +Callbacks registered with ``afterCommit()`` or ``afterRollback()`` inside the +transaction callback follow the same rules as other transaction callbacks: they +run only after the outermost transaction commits or rolls back. + +Scoped Transaction Options +-------------------------- + +The ``transaction()`` method can temporarily override existing transaction +options for the duration of the helper call: + +.. literalinclude:: transactions/017.php + +Set ``transException`` to temporarily enable or disable the same transaction +exception mode configured by ``transException()``. The previous mode is restored +after ``transaction()`` finishes, even if the callback throws an exception. +If ``transaction()`` is called inside an active transaction, the temporary mode +applies while the nested callback runs, then the previous mode is restored for +the outer transaction. When ``transException`` is set to ``true`` in a nested +transaction and a query fails, CodeIgniter's existing transaction exception +handling rolls back the outer transaction as well. + +Set ``resetTransStatus`` to reset the transaction status before the helper starts +an outermost transaction. This is equivalent to calling ``resetTransStatus()`` +before the transaction, and is useful when strict mode has marked the connection +as failed from an earlier transaction. + +``resetTransStatus`` only applies when ``transaction()`` starts the outermost +transaction. If ``transaction()`` is called inside an active transaction, it does +not reset the outer transaction status. + +The ``transException`` option follows CodeIgniter's existing transaction +exception behavior, including the current ``DBDebug`` setting. + +Retrying Transactions +--------------------- + +You may pass ``attempts`` when you want CodeIgniter to retry the whole +transaction after retryable database concurrency failures, such as deadlocks or +serialization failures. Retries happen when a ``RetryableTransactionException`` +occurs while the callback is running, including from query or prepared query +execution: + +.. literalinclude:: transactions/016.php + +``attempts`` is the total number of times to try the transaction, including the +first run, and must be greater than or equal to ``1``. + +Keep these rules in mind when using retry attempts: + +- The callback may run more than once, and retries run immediately without delay + or backoff. +- Retries only apply when ``transaction()`` starts the outermost transaction. If + ``transaction()`` is called inside an active transaction, the callback runs + once using the existing nested transaction behavior. +- Only ``RetryableTransactionException`` failures are retried. Other exceptions + are rolled back and rethrown without another attempt. +- Retry attempts do not retry failures that are reported only while the + transaction is committing. +- If an ``afterRollback()`` callback throws while a failed attempt is rolling + back, that callback exception bubbles to the caller and no further attempts are + made. + +Avoid non-transactional side effects inside callbacks that may be retried. For +side effects such as queued jobs, emails, cache invalidation, events, or external +API calls, register them with ``afterCommit()`` so they run only after the final +successful commit. + +``afterRollback()`` callbacks may run for failed retry attempts even when a later +attempt commits successfully, so use them only for cleanup that is safe to run +per rolled-back attempt. + +.. _transactions-retryable-exceptions: + +Classifying Retryable Transaction Failures +========================================== + +.. versionadded:: 4.8.0 + +Some database engines report transaction failures that may succeed when the +entire transaction is attempted again, such as deadlocks or serialization +failures. When a driver classifies a query or prepared query execution failure as +one of these retryable transaction failures, CodeIgniter throws +``RetryableTransactionException`` so you can decide how your application should +respond: + +.. literalinclude:: transactions/015.php + +``RetryableTransactionException`` is the classifier used by ``transaction()`` +retry attempts. If you are not using ``transaction()`` attempts, catch this +exception and retry the whole transaction according to your application's policy. +Avoid non-transactional side effects inside transaction bodies that may be +retried. For side effects such as queued jobs, emails, cache invalidation, or +external API calls, register them with ``afterCommit()`` so they run only after +the transaction commits. + +When ``DBDebug`` is ``false`` and a failed query or prepared query returns +``false`` instead of throwing, inspect ``getLastException()`` immediately after +the failed operation. It will contain the ``RetryableTransactionException`` +instance when the driver classifies the failure as retryable. + Strict Mode =========== @@ -111,6 +240,100 @@ If you want an exception to be thrown when a query error occurs, you can use If a query error occurs, all the queries will be rolled backed, and a ``DatabaseException`` will be thrown. +.. _transactions-transaction-callbacks: + +Running Code after Commit or Rollback +===================================== + +.. versionadded:: 4.8.0 + +You may register callbacks to run only after the outermost transaction has +successfully committed by using the ``afterCommit()`` method, or after the +outermost transaction has rolled back by using the ``afterRollback()`` method: + +.. literalinclude:: transactions/010.php + +Callbacks registered during an active transaction are delayed until the +outermost transaction commits or rolls back. + +If the transaction commits, ``afterCommit()`` callbacks run and +``afterRollback()`` callbacks are discarded. If the transaction rolls back, +``afterRollback()`` callbacks run and ``afterCommit()`` callbacks are discarded. +If no transaction is active, ``afterCommit()`` callbacks run immediately, while +``afterRollback()`` callbacks are not run. + +For example: + +.. literalinclude:: transactions/011.php + +.. note:: When ``afterCommit()`` is called outside an active transaction, it runs + immediately. This includes calls from Model ``beforeInsert`` or + ``beforeUpdate`` events when the calling code has not already started a + transaction, so the callback may run before the Model's insert or update + query is executed. + +This is useful for side effects that should only happen after committed data is +visible, such as dispatching a queued job or sending a notification, and for +cleanup that should only happen after a real rollback. + +Deferring Side Effects +---------------------- + +Code that changes external state should usually not run in the middle of a +transaction. If the transaction rolls back after the side effect has already +run, the application may send a notification for data that was never saved, +invalidate a cache for a write that did not persist, or start background work +that cannot find the committed row it expects. + +Register those side effects with ``afterCommit()`` instead: + +.. literalinclude:: transactions/013.php + +Use ``afterRollback()`` for cleanup that should happen only when the transaction +does not commit, such as removing a temporary file created before the database +write: + +.. literalinclude:: transactions/014.php + +Model callbacks such as ``afterInsert`` and ``afterUpdate`` run after the Model +query has executed, but not necessarily after the surrounding transaction has +committed. If a Model callback needs to run a side effect only after commit, it +should register that work with the database connection's ``afterCommit()`` +method while an active transaction is already open. + +Callbacks run after the database transaction has already committed or rolled +back. If a callback throws an exception, that exception bubbles to the caller, +but the transaction outcome is not changed. + +.. warning:: When multiple callbacks are registered for the same transaction + outcome, they run in registration order. If one callback throws an exception, + the subsequent callbacks are not run. + +Rollback callbacks also run when CodeIgniter automatically rolls back an active +transaction while handling a transaction failure or cleaning up an unfinished +transaction. + +.. _transactions-checking-transaction-state: + +Checking Transaction State +========================== + +.. versionadded:: 4.8.0 + +You may use ``inTransaction()`` to check whether the connection is currently +inside an active CodeIgniter-managed transaction. + +It returns ``false`` when no CodeIgniter-managed transaction is active, +including when transactions are disabled. + +This is useful for services or libraries that need to adapt their behavior when +they are called from inside an existing transaction. + +.. note:: ``inTransaction()`` reflects transactions started through + CodeIgniter's transaction methods. If you start or end transactions through + raw SQL or driver-specific APIs, CodeIgniter will not be aware of those + transactions. + Disabling Transactions ====================== diff --git a/user_guide_src/source/database/transactions/010.php b/user_guide_src/source/database/transactions/010.php new file mode 100644 index 000000000000..84f0ae2959b6 --- /dev/null +++ b/user_guide_src/source/database/transactions/010.php @@ -0,0 +1,14 @@ +db->transStart(); +$this->db->query('AN SQL QUERY...'); + +$this->db->afterCommit(static function (): void { + // Dispatch a queued job or run another side effect after commit. +}); + +$this->db->afterRollback(static function (): void { + // Run cleanup that should only happen after rollback. +}); + +$this->db->transComplete(); diff --git a/user_guide_src/source/database/transactions/011.php b/user_guide_src/source/database/transactions/011.php new file mode 100644 index 000000000000..d92425fd897d --- /dev/null +++ b/user_guide_src/source/database/transactions/011.php @@ -0,0 +1,9 @@ +db->afterCommit(static function (): void { + // Runs immediately because no transaction has started yet. +}); + +$this->db->transStart(); +$this->db->query('AN SQL QUERY...'); +$this->db->transComplete(); diff --git a/user_guide_src/source/database/transactions/012.php b/user_guide_src/source/database/transactions/012.php new file mode 100644 index 000000000000..a017036864fb --- /dev/null +++ b/user_guide_src/source/database/transactions/012.php @@ -0,0 +1,14 @@ +db->transaction(static function ($db) use ($order, $id) { + $db->table('orders')->insert($order); + $orderId = $db->insertID(); + + $db->table('stock')->where('id', $id)->decrement('qty'); + + $db->afterCommit(static function (): void { + // Dispatch a job or send a notification after commit. + }); + + return $orderId; +}); diff --git a/user_guide_src/source/database/transactions/013.php b/user_guide_src/source/database/transactions/013.php new file mode 100644 index 000000000000..534094bbabbb --- /dev/null +++ b/user_guide_src/source/database/transactions/013.php @@ -0,0 +1,22 @@ +db->transaction(static function ($db) use ($order): int { + $db->table('orders')->insert($order); + $insertedOrderId = $db->insertID(); + + $db->afterCommit(static function () use ($insertedOrderId): void { + service('cache')->delete('orders_list'); + Events::trigger('order_created', $insertedOrderId); + + // Dispatch a queued job or send a notification here. + // The new order is committed and visible to other database connections. + }); + + return $insertedOrderId; +}); + +if ($orderId === false) { + // Handle the transaction failure. +} diff --git a/user_guide_src/source/database/transactions/014.php b/user_guide_src/source/database/transactions/014.php new file mode 100644 index 000000000000..ce39da2fb0fc --- /dev/null +++ b/user_guide_src/source/database/transactions/014.php @@ -0,0 +1,11 @@ +db->transaction(static function ($db) use ($temporaryPath, $record): void { + $db->afterRollback(static function () use ($temporaryPath): void { + if (is_file($temporaryPath)) { + unlink($temporaryPath); + } + }); + + $db->table('documents')->insert($record); +}); diff --git a/user_guide_src/source/database/transactions/015.php b/user_guide_src/source/database/transactions/015.php new file mode 100644 index 000000000000..b5a6550cd6fb --- /dev/null +++ b/user_guide_src/source/database/transactions/015.php @@ -0,0 +1,14 @@ +transException(true)->transaction(static function ($db) { + $db->table('orders')->insert($order); + + return $db->insertID(); + }); +} catch (RetryableTransactionException $e) { + // Retry the whole transaction according to your application's policy. + throw $e; +} diff --git a/user_guide_src/source/database/transactions/016.php b/user_guide_src/source/database/transactions/016.php new file mode 100644 index 000000000000..0ed15f615363 --- /dev/null +++ b/user_guide_src/source/database/transactions/016.php @@ -0,0 +1,7 @@ +db->transaction(static function ($db) use ($order) { + $db->table('orders')->insert($order); + + return $db->insertID(); +}, attempts: 3); diff --git a/user_guide_src/source/database/transactions/017.php b/user_guide_src/source/database/transactions/017.php new file mode 100644 index 000000000000..33f80e796bc3 --- /dev/null +++ b/user_guide_src/source/database/transactions/017.php @@ -0,0 +1,11 @@ +db->transaction( + static function ($db) use ($order) { + $db->table('orders')->insert($order); + + return $db->insertID(); + }, + transException: true, + resetTransStatus: true, +); diff --git a/user_guide_src/source/dbmgmt/migration.rst b/user_guide_src/source/dbmgmt/migration.rst index a9baed5b9c62..384a0bdd23cb 100644 --- a/user_guide_src/source/dbmgmt/migration.rst +++ b/user_guide_src/source/dbmgmt/migration.rst @@ -132,8 +132,8 @@ Migrates a database group with all available migrations: You can use ``migrate`` with the following options: -- ``-g`` - to specify database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run. -- ``-n`` - to choose namespace, otherwise ``App`` namespace will be used. +- ``--group`` (``-g``) - to specify database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run. +- ``--namespace`` (``-n``) - to choose namespace, otherwise ``App`` namespace will be used. - ``--all`` - to migrate all namespaces to the latest migration. This example will migrate ``Acme\Blog`` namespace with any new migrations on the test database group: @@ -165,8 +165,8 @@ Rolls back all migrations to a blank slate, effectively migration 0: You can use ``migrate:rollback`` with the following options: -- ``-b`` - to choose a batch: natural numbers specify the batch. -- ``-f`` - to force a bypass confirmation question, it is only asked in a production environment. +- ``--batch`` (``-b``) - to choose a batch: natural numbers specify the batch. +- ``--force`` (``-f``) - to force a bypass confirmation question, it is only asked in a production environment. migrate:refresh =============== @@ -179,10 +179,10 @@ Refreshes the database state by first rolling back all migrations, and then migr You can use ``migrate:refresh`` with the following options: -- ``-g`` - to specify database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run. -- ``-n`` - to choose namespace, otherwise ``App`` namespace will be used. +- ``--group`` (``-g``) - to specify database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run. +- ``--namespace`` (``-n``) - to choose namespace, otherwise ``App`` namespace will be used. - ``--all`` - to refresh all namespaces. -- ``-f`` - to force a bypass confirmation question, it is only asked in a production environment. +- ``--force`` (``-f``) - to force a bypass confirmation question, it is only asked in a production environment. migrate:status ============== @@ -205,7 +205,7 @@ Displays a list of all migrations and the date and time they ran, or '--' if the You can use ``migrate:status`` with the following options: -- ``-g`` - to specify database group. If specified, only migrations for the specified database group will be checked. If not specified, all migrations will be checked. +- ``--group`` (``-g``) - to specify database group. If specified, only migrations for the specified database group will be checked. If not specified, all migrations will be checked. make:migration ============== diff --git a/user_guide_src/source/extending/core_classes.rst b/user_guide_src/source/extending/core_classes.rst index 2e4b1ac134a3..00ed323f04d6 100644 --- a/user_guide_src/source/extending/core_classes.rst +++ b/user_guide_src/source/extending/core_classes.rst @@ -51,6 +51,7 @@ The following is a list of the core system classes that are invoked every time C * ``CodeIgniter\HTTP\SiteURIFactory`` * ``CodeIgniter\HTTP\URI`` * ``CodeIgniter\HTTP\UserAgent`` (if launched over HTTP) +* ``CodeIgniter\Lock\LockManager`` * ``CodeIgniter\Log\Logger`` * ``CodeIgniter\Log\Handlers\BaseHandler`` * ``CodeIgniter\Log\Handlers\FileHandler`` diff --git a/user_guide_src/source/general/configuration.rst b/user_guide_src/source/general/configuration.rst index 601ab6e81177..2469966eb097 100644 --- a/user_guide_src/source/general/configuration.rst +++ b/user_guide_src/source/general/configuration.rst @@ -342,6 +342,99 @@ Registrar methods must always return an array, with keys corresponding to the pr of the target config file. Existing values are merged, and Registrar properties have overwrite priority. +By default this merge is **shallow** (top-level only). Arrays are combined with +``array_merge()``, so a nested array under a top-level key *replaces* the existing +nested array rather than merging into it. In the following example the ``key2`` +subtree from the config is replaced entirely, dropping ``val2`` and ``val3``: + +.. literalinclude:: configuration/012.php + +.. _registrar-merge-directives: + +Controlling how values are merged +--------------------------------- + +.. versionadded:: 4.8.0 + +When a registrar needs finer control than the shallow default, it can return a +``CodeIgniter\Config\Merge`` directive as the value of a property. Merge +directives are explicit instructions to either keep merging into a value, replace +it, or add items to a list. + +The two directives that control whole values are: + +- ``Merge::replace($value)`` - discard the existing value and use ``$value``. + Accepts **any type** (scalar, ``null``, or array - e.g. ``['a', 'b']`` becomes + ``['c']``, or ``Merge::replace('redis')``). +- ``Merge::byKey($value)`` - deep-merge by key: **string keys recurse, integer + keys append, and scalar leaves are replaced**. The name is deliberately not + ``recursive`` to avoid confusion with PHP's ``array_merge_recursive()``, which + collects scalar values into arrays instead of replacing them. + +Use ``Merge::byKey()`` when you want to navigate into nested configuration and +preserve sibling keys: + +.. literalinclude:: configuration/013.php + +.. important:: Inside ``Merge::byKey()``, plain arrays are still merged by key. + Use ``Merge::replace()`` when you want to stop merging at that key and + overwrite the value. For example, ``'after' => []`` leaves an existing + ``after`` list unchanged, while ``'after' => Merge::replace([])`` clears it. + +The following registrar adds a filter to ``globals['before']`` while hard-resetting +``globals['after']``, leaving any other ``globals`` keys untouched: + +.. literalinclude:: configuration/014.php + +The *list* strategies add items to a list and control where they land. They are +useful where order matters, such as the filter lists in ``Config\Filters``: + +- ``Merge::append($value)`` - add items to the **end** of the list + (e.g. ``['a', 'b']`` becomes ``['a', 'b', 'c']``). +- ``Merge::prepend($value)`` - add items to the **front** of the list + (e.g. ``['a', 'b']`` becomes ``['c', 'a', 'b']``). +- ``Merge::before($anchor, $value)`` - insert items immediately **before** the + first element equal to ``$anchor``. +- ``Merge::after($anchor, $value)`` - insert items immediately **after** the + first element equal to ``$anchor``. + +All four de-duplicate, so the directives never *introduce* a duplicate value: a +value already in the list is not added again, and duplicate payload values are +collapsed (e.g. ``Merge::append(['x', 'x'])`` adds ``x`` once). Duplicates that +already exist in the current list are left as-is. They differ only in how they +treat a value that is *already present*: + +- ``append()`` / ``prepend()`` leave an already-present value **where it is** + (they only add values that are missing). +- ``before()`` / ``after()`` **move** an already-present value to the anchor + position - but only when the anchor is in the list. If the anchor is missing + they fall back to ``append()`` / ``prepend()`` respectively, and do **not** + relocate a value that is already present. + +The anchor is matched strictly against the **direct elements** of the list; the +list strategies act on a single list level and never recurse. To reach a list +that is nested under other keys (such as ``globals['before']``), navigate to it +with ``Merge::byKey()`` and place the list directive at that key: + +.. literalinclude:: configuration/015.php + +.. important:: Merge directives are interpreted only when used as the **value of + a config property** returned by a registrar, and recursively **inside** + ``Merge::byKey()``. The payloads of the ``replace()``, ``append()``, + ``prepend()``, ``before()``, and ``after()`` strategies are taken literally and + are **not** scanned for nested directives - for nested control, wrap the + property in ``Merge::byKey()`` and place the directives at its keys. + +.. note:: Merge directives sharpen a *single* registrar's intent; they do not add + an explicit priority mechanism between registrars. Registrars are still applied + in discovery order, and an item's final position follows from that order *plus* + the strategy: with ``append()`` an earlier registrar's items sit ahead of a + later one's, while with ``prepend()``, ``before()``, and ``after()`` a later + registrar's items land **nearer the front or the anchor** than an earlier one's. + For example, ``after('csrf', ['a'])`` followed by ``after('csrf', ['b'])`` from a + second registrar yields ``['csrf', 'b', 'a']``. As always, **.env** values take + priority over registrars. + Explicit Registrars =================== diff --git a/user_guide_src/source/general/configuration/012.php b/user_guide_src/source/general/configuration/012.php new file mode 100644 index 000000000000..25170d351902 --- /dev/null +++ b/user_guide_src/source/general/configuration/012.php @@ -0,0 +1,35 @@ + 'val1', + 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'], + ]; +} + +// Modules/MyModule/Config/Registrar.php - plain array (shallow merge) + +namespace MyModule\Config; + +class Registrar +{ + public static function Example(): array + { + return ['arrayNested' => ['key2' => ['val4' => 'subVal4']]]; + } +} + +// Result - the nested array under "key2" is replaced wholesale, so +// "val2" and "val3" are silently dropped: +// +// 'arrayNested' => [ +// 'key1' => 'val1', +// 'key2' => ['val4' => 'subVal4'], +// ] diff --git a/user_guide_src/source/general/configuration/013.php b/user_guide_src/source/general/configuration/013.php new file mode 100644 index 000000000000..2bff86e2993f --- /dev/null +++ b/user_guide_src/source/general/configuration/013.php @@ -0,0 +1,26 @@ + Merge::byKey([ + 'key2' => ['val4' => 'subVal4'], + ]), + ]; + } +} + +// Result - the sibling keys are preserved: +// +// 'arrayNested' => [ +// 'key1' => 'val1', +// 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3', 'val4' => 'subVal4'], +// ] diff --git a/user_guide_src/source/general/configuration/014.php b/user_guide_src/source/general/configuration/014.php new file mode 100644 index 000000000000..2be7eee8e516 --- /dev/null +++ b/user_guide_src/source/general/configuration/014.php @@ -0,0 +1,26 @@ + Merge::byKey([ + 'before' => Merge::append(['blogFilter']), // add to the existing list + 'after' => Merge::replace([]), // hard reset, plain [] would keep merging + ]), + ]; + } + + // Scalar replace also works at the property root: + public static function Cache(): array + { + return ['handler' => Merge::replace('redis')]; + } +} diff --git a/user_guide_src/source/general/configuration/015.php b/user_guide_src/source/general/configuration/015.php new file mode 100644 index 000000000000..75dc2f9152a4 --- /dev/null +++ b/user_guide_src/source/general/configuration/015.php @@ -0,0 +1,29 @@ + Merge::byKey([ + // Run "auth" immediately after "csrf" in the before-list. + 'before' => Merge::after('csrf', ['auth']), + // Run "honeypot" first in the after-list. + 'after' => Merge::prepend(['honeypot']), + ]), + ]; + } +} + +// Given a base of: +// 'before' => ['csrf', 'invalidchars'], +// 'after' => ['toolbar'], +// the result is: +// 'before' => ['csrf', 'auth', 'invalidchars'], +// 'after' => ['honeypot', 'toolbar'], diff --git a/user_guide_src/source/general/context.rst b/user_guide_src/source/general/context.rst new file mode 100644 index 000000000000..5c9dac35b16b --- /dev/null +++ b/user_guide_src/source/general/context.rst @@ -0,0 +1,374 @@ +.. _context: + +################### +Context +################### + +.. versionadded:: 4.8.0 + +.. contents:: + :local: + :depth: 2 + +*********** +What is it? +*********** + +The Context class provides a simple, convenient way to store and retrieve user-defined data throughout a single request. It functions as a key-value store that can hold any data you need to access across different parts of your application during the request lifecycle. + +The Context class is particularly useful for: + +- Storing request-specific metadata (user IDs, request IDs, correlation IDs) +- Passing data between filters, controllers, and other components +- Adding contextual information to your logs automatically +- Storing sensitive data that should not appear in logs + +*********************** +Accessing Context Class +*********************** + +You can access the Context service anywhere in your application using the ``service()`` function or ``context()`` helper: + +.. literalinclude:: context/001.php + +********************* +Setting Context Data +********************* + +Setting a Single Value +====================== + +You can store a single key-value pair using the ``set()`` method: + +.. literalinclude:: context/002.php + +Setting Multiple Values +======================= + +You can also set multiple values at once by passing an array: + +.. literalinclude:: context/003.php + +The ``set()`` method returns the Context instance, allowing you to chain multiple calls: + +.. literalinclude:: context/004.php + +********************* +Getting Context Data +********************* + +Retrieving a Single Value +========================== + +Use the ``get()`` method to retrieve a value by its key: + +.. literalinclude:: context/005.php + +You can provide a default value as the second parameter, which will be returned if the key doesn't exist: + +.. literalinclude:: context/006.php + +Retrieving All Data +=================== + +To get all stored context data: + +.. literalinclude:: context/007.php + +Retrieving Specific Keys +========================= + +You can retrieve only specific keys using ``getOnly()``: + +.. literalinclude:: context/008.php + +If you need all data except specific keys, use ``getExcept()``: + +.. literalinclude:: context/009.php + +********************** +Checking for Data +********************** + +You can check if a key exists in the context: + +.. literalinclude:: context/010.php + +********************* +Removing Context Data +********************* + +Removing a Single Value +======================== + +You can remove data from the context using the ``remove()`` method: + +.. literalinclude:: context/011.php + +Removing Multiple Values +========================= + +To remove multiple keys at once, pass an array: + +.. literalinclude:: context/012.php + +Clearing All Data +================= + +To remove all context data: + +.. literalinclude:: context/013.php + +********************* +Hidden Context Data +********************* + +The Context class provides a separate storage area for sensitive data that should not be included in logs. +This is useful for storing API keys, passwords, tokens, or other sensitive information that you need to access +during the request but don't want to expose in log files. + +Setting Hidden Data +=================== + +Use the ``setHidden()`` method to store sensitive data: + +.. literalinclude:: context/014.php + +You can also set multiple hidden values at once: + +.. literalinclude:: context/015.php + +Getting Hidden Data +=================== + +Retrieve hidden data using ``getHidden()``: + +.. literalinclude:: context/016.php + +The same methods available for regular data also work with hidden data: + +.. literalinclude:: context/017.php + +Checking Hidden Data +==================== + +Check if a hidden key exists: + +.. literalinclude:: context/018.php + +Removing Hidden Data +==================== + +Remove hidden data using ``removeHidden()``: + +.. literalinclude:: context/019.php + +Clearing Hidden Data +==================== + +To clear all hidden data without affecting regular context data: + +.. literalinclude:: context/020.php + +To clear both regular and hidden data: + +.. literalinclude:: context/021.php + +.. important:: Regular data and hidden data are stored separately. A key can exist in both regular and hidden storage with different values. Use ``get()`` for regular data and ``getHidden()`` for hidden data. + +*********************************** +Integration with Logging +*********************************** + +The Context class integrates seamlessly with CodeIgniter's logging system. When enabled, context data is automatically +appended to log messages, providing additional information for debugging and monitoring. + +Enabling Global Context Logging +================================ + +To enable automatic logging of context data, set the ``$logGlobalContext`` property to ``true`` in your +**app/Config/Logger.php** file: + +.. literalinclude:: context/022.php + +When enabled, all context data (excluding hidden data) will be automatically appended to your log messages as JSON: + +.. literalinclude:: context/023.php + +This would produce a log entry like: + +.. code-block:: text + + ERROR - 2026-02-18 --> Payment processing failed {"user_id":123,"transaction_id":"txn_12345"} + +.. note:: Hidden data set with ``setHidden()`` is **never** included in logs, even when ``$logGlobalContext`` is enabled. This ensures sensitive information like API keys or tokens remain secure. + +*************** +Important Notes +*************** + +- Context data persists only for the duration of a single request. It is not shared between requests. +- The Context service is shared by default, meaning there is one instance per request. +- Hidden data is never included in logs, regardless of the logging configuration. +- Regular context data and hidden context data are stored separately and can have overlapping keys. +- Context is cleared automatically at the end of each request. +- In testing environments, remember to clear context data between tests using ``clearAll()`` to ensure test isolation. + +*************** +Class Reference +*************** + +.. php:namespace:: CodeIgniter\Context + +.. php:class:: Context + + .. php:method:: set($key[, $value = null]) + + :param array|string $key: The key or an array of key-value pairs + :param mixed $value: The value to store (ignored if $key is an array) + :returns: Context instance for method chaining + :rtype: Context + + Sets one or more key-value pairs in the context. + + .. php:method:: setHidden($key[, $value = null]) + + :param array|string $key: The key or an array of key-value pairs + :param mixed $value: The value to store (ignored if $key is an array) + :returns: Context instance for method chaining + :rtype: Context + + Sets one or more key-value pairs in the hidden context. + + .. php:method:: get($key[, $default = null]) + + :param string $key: The key to retrieve + :param mixed $default: Default value if key doesn't exist + :returns: The value or default + :rtype: mixed + + Gets a value from the context. + + .. php:method:: getHidden($key[, $default = null]) + + :param string $key: The key to retrieve + :param mixed $default: Default value if key doesn't exist + :returns: The value or default + :rtype: mixed + + Gets a value from the hidden context. + + .. php:method:: getOnly($keys) + + :param array|string $keys: Key or array of keys to retrieve + :returns: Array of key-value pairs + :rtype: array + + Gets only the specified keys from the context. + + .. php:method:: getOnlyHidden($keys) + + :param array|string $keys: Key or array of keys to retrieve + :returns: Array of key-value pairs + :rtype: array + + Gets only the specified keys from the hidden context. + + .. php:method:: getExcept($keys) + + :param array|string $keys: Key or array of keys to exclude + :returns: Array of key-value pairs + :rtype: array + + Gets all context data except the specified keys. + + .. php:method:: getExceptHidden($keys) + + :param array|string $keys: Key or array of keys to exclude + :returns: Array of key-value pairs + :rtype: array + + Gets all hidden context data except the specified keys. + + .. php:method:: getAll() + + :returns: All context data + :rtype: array + + Gets all data from the context. + + .. php:method:: getAllHidden() + + :returns: All hidden context data + :rtype: array + + Gets all data from the hidden context. + + .. php:method:: has($key) + + :param string $key: The key to check + :returns: True if key exists, false otherwise + :rtype: bool + + Checks if a key exists in the context. + + .. php:method:: hasHidden($key) + + :param string $key: The key to check + :returns: True if key exists, false otherwise + :rtype: bool + + Checks if a key exists in the hidden context. + + .. php:method:: missing($key) + + :param string $key: The key to check + :returns: True if key doesn't exist, false otherwise + :rtype: bool + + Checks if a key doesn't exist in the context. Opposite of ``has()``. + + .. php:method:: missingHidden($key) + + :param string $key: The key to check + :returns: True if key doesn't exist, false otherwise + :rtype: bool + + Checks if a key doesn't exist in the hidden context. Opposite of ``hasHidden()``. + + .. php:method:: remove($key) + + :param array|string $key: The key or array of keys to remove + :returns: Context instance for method chaining + :rtype: Context + + Removes one or more keys from the context. + + .. php:method:: removeHidden($key) + + :param array|string $key: The key or array of keys to remove + :returns: Context instance for method chaining + :rtype: Context + + Removes one or more keys from the hidden context. + + .. php:method:: clear() + + :returns: Context instance for method chaining + :rtype: Context + + Clears all data from the context (does not affect hidden data). + + .. php:method:: clearHidden() + + :returns: Context instance for method chaining + :rtype: Context + + Clears all data from the hidden context (does not affect regular data). + + .. php:method:: clearAll() + + :returns: Context instance for method chaining + :rtype: Context + + Clears all data from both the context and hidden context. diff --git a/user_guide_src/source/general/context/001.php b/user_guide_src/source/general/context/001.php new file mode 100644 index 000000000000..53004b9f25c5 --- /dev/null +++ b/user_guide_src/source/general/context/001.php @@ -0,0 +1,6 @@ +set('user_id', 123); diff --git a/user_guide_src/source/general/context/003.php b/user_guide_src/source/general/context/003.php new file mode 100644 index 000000000000..60d2ca87ee2f --- /dev/null +++ b/user_guide_src/source/general/context/003.php @@ -0,0 +1,8 @@ +set([ + 'user_id' => 123, + 'username' => 'john_doe', + 'request_id' => 'req_abc123', + 'correlation_id' => 'corr_xyz789', +]); diff --git a/user_guide_src/source/general/context/004.php b/user_guide_src/source/general/context/004.php new file mode 100644 index 000000000000..29dd1e38cafc --- /dev/null +++ b/user_guide_src/source/general/context/004.php @@ -0,0 +1,5 @@ +set('user_id', 123) + ->set('username', 'john_doe') + ->set('request_id', 'req_abc123'); diff --git a/user_guide_src/source/general/context/005.php b/user_guide_src/source/general/context/005.php new file mode 100644 index 000000000000..1ddd78820180 --- /dev/null +++ b/user_guide_src/source/general/context/005.php @@ -0,0 +1,4 @@ +get('user_id'); +// $userId = 123 diff --git a/user_guide_src/source/general/context/006.php b/user_guide_src/source/general/context/006.php new file mode 100644 index 000000000000..c56335f6bc27 --- /dev/null +++ b/user_guide_src/source/general/context/006.php @@ -0,0 +1,4 @@ +get('user_role', 'guest'); +// If 'user_role' doesn't exist, $role will be 'guest' diff --git a/user_guide_src/source/general/context/007.php b/user_guide_src/source/general/context/007.php new file mode 100644 index 000000000000..9ad340998671 --- /dev/null +++ b/user_guide_src/source/general/context/007.php @@ -0,0 +1,4 @@ +getAll(); +// Returns: ['user_id' => 123, 'username' => 'john_doe', ...] diff --git a/user_guide_src/source/general/context/008.php b/user_guide_src/source/general/context/008.php new file mode 100644 index 000000000000..519ecf64e174 --- /dev/null +++ b/user_guide_src/source/general/context/008.php @@ -0,0 +1,8 @@ +getOnly(['user_id', 'username']); +// Returns: ['user_id' => 123, 'username' => 'john_doe'] + +// You can also pass a single key as a string +$userId = $context->getOnly('user_id'); +// Returns: ['user_id' => 123] diff --git a/user_guide_src/source/general/context/009.php b/user_guide_src/source/general/context/009.php new file mode 100644 index 000000000000..2c5158dd36b0 --- /dev/null +++ b/user_guide_src/source/general/context/009.php @@ -0,0 +1,8 @@ +getExcept(['password', 'api_key']); +// Returns all data except 'password' and 'api_key' + +// You can also pass a single key as a string +$data = $context->getExcept('password'); +// Returns all data except 'password' diff --git a/user_guide_src/source/general/context/010.php b/user_guide_src/source/general/context/010.php new file mode 100644 index 000000000000..61b10e76a4c5 --- /dev/null +++ b/user_guide_src/source/general/context/010.php @@ -0,0 +1,5 @@ +has('user_id')) { + // Do something with user_id +} diff --git a/user_guide_src/source/general/context/011.php b/user_guide_src/source/general/context/011.php new file mode 100644 index 000000000000..2c6c3598ab91 --- /dev/null +++ b/user_guide_src/source/general/context/011.php @@ -0,0 +1,3 @@ +remove('user_id'); diff --git a/user_guide_src/source/general/context/012.php b/user_guide_src/source/general/context/012.php new file mode 100644 index 000000000000..5922fd6605ac --- /dev/null +++ b/user_guide_src/source/general/context/012.php @@ -0,0 +1,3 @@ +remove(['user_id', 'username', 'request_id']); diff --git a/user_guide_src/source/general/context/013.php b/user_guide_src/source/general/context/013.php new file mode 100644 index 000000000000..18746e68281d --- /dev/null +++ b/user_guide_src/source/general/context/013.php @@ -0,0 +1,3 @@ +clear(); diff --git a/user_guide_src/source/general/context/014.php b/user_guide_src/source/general/context/014.php new file mode 100644 index 000000000000..560f233a600a --- /dev/null +++ b/user_guide_src/source/general/context/014.php @@ -0,0 +1,3 @@ +setHidden('api_key', 'sk_live_abc123xyz789'); diff --git a/user_guide_src/source/general/context/015.php b/user_guide_src/source/general/context/015.php new file mode 100644 index 000000000000..b7b3a906ac78 --- /dev/null +++ b/user_guide_src/source/general/context/015.php @@ -0,0 +1,7 @@ +setHidden([ + 'api_key' => 'sk_live_abc123xyz789', + 'api_secret' => 'secret_key_here', + 'db_password' => 'database_password', +]); diff --git a/user_guide_src/source/general/context/016.php b/user_guide_src/source/general/context/016.php new file mode 100644 index 000000000000..e26ceda619ff --- /dev/null +++ b/user_guide_src/source/general/context/016.php @@ -0,0 +1,4 @@ +getHidden('api_key'); +// $apiKey = 'sk_live_abc123xyz789' diff --git a/user_guide_src/source/general/context/017.php b/user_guide_src/source/general/context/017.php new file mode 100644 index 000000000000..9c52af5e6b05 --- /dev/null +++ b/user_guide_src/source/general/context/017.php @@ -0,0 +1,13 @@ +getHidden('api_key', 'default_key'); + +// Get only specific hidden keys +$credentials = $context->getOnlyHidden(['api_key', 'api_secret']); + +// Get all hidden data except specific keys +$data = $context->getExceptHidden(['db_password']); + +// Get all hidden data +$allHidden = $context->getAllHidden(); diff --git a/user_guide_src/source/general/context/018.php b/user_guide_src/source/general/context/018.php new file mode 100644 index 000000000000..b5084a23f143 --- /dev/null +++ b/user_guide_src/source/general/context/018.php @@ -0,0 +1,5 @@ +hasHidden('api_key')) { + // API key is set +} diff --git a/user_guide_src/source/general/context/019.php b/user_guide_src/source/general/context/019.php new file mode 100644 index 000000000000..a06a3f6d9c3b --- /dev/null +++ b/user_guide_src/source/general/context/019.php @@ -0,0 +1,7 @@ +removeHidden('api_key'); + +// Remove multiple hidden values +$context->removeHidden(['api_key', 'api_secret']); diff --git a/user_guide_src/source/general/context/020.php b/user_guide_src/source/general/context/020.php new file mode 100644 index 000000000000..7fca9aa5869f --- /dev/null +++ b/user_guide_src/source/general/context/020.php @@ -0,0 +1,3 @@ +clearHidden(); diff --git a/user_guide_src/source/general/context/021.php b/user_guide_src/source/general/context/021.php new file mode 100644 index 000000000000..bc26dead4e00 --- /dev/null +++ b/user_guide_src/source/general/context/021.php @@ -0,0 +1,3 @@ +clearAll(); diff --git a/user_guide_src/source/general/context/022.php b/user_guide_src/source/general/context/022.php new file mode 100644 index 000000000000..7e9f878c30bb --- /dev/null +++ b/user_guide_src/source/general/context/022.php @@ -0,0 +1,14 @@ +set('user_id', 123); +$context->set('transaction_id', 'txn_12345'); + +log_message('error', 'Payment processing failed'); diff --git a/user_guide_src/source/general/environments.rst b/user_guide_src/source/general/environments.rst index 428894ad75b5..baad733e3cb5 100644 --- a/user_guide_src/source/general/environments.rst +++ b/user_guide_src/source/general/environments.rst @@ -145,6 +145,33 @@ You can also check the current environment by ``spark env`` command: php spark env +.. _environment-detector-service: + +The ``environment`` service +=========================== + +.. versionadded:: 4.8.0 + +As an alternative to reading the ``ENVIRONMENT`` constant directly, CodeIgniter +provides the ``environment`` service, backed by the +``CodeIgniter\EnvironmentDetector`` class. Because it is a shared service, it +can be mocked in tests (via ``Services::injectMock()``) to exercise +environment-specific branches without having to redefine the ``ENVIRONMENT`` +constant. + +.. literalinclude:: environments/001.php + +.. note:: + + The ``environment`` service is primarily intended for testing + environment-specific code paths. Mocking it only affects code that resolves + and uses the service itself. It does not modify the ``ENVIRONMENT`` constant. + Code that still reads ``ENVIRONMENT`` directly keeps its current behavior. + +Passing a value to the constructor overrides the detected environment; passing +``null`` (the default) falls back to the ``ENVIRONMENT`` constant. An empty or +whitespace-only string throws ``CodeIgniter\Exceptions\InvalidArgumentException``. + ************************************* Effects on Default Framework Behavior ************************************* diff --git a/user_guide_src/source/general/environments/001.php b/user_guide_src/source/general/environments/001.php new file mode 100644 index 000000000000..06df70c2f8a4 --- /dev/null +++ b/user_guide_src/source/general/environments/001.php @@ -0,0 +1,14 @@ +get(); + +// Check against the three built-in environments. +$environment->isProduction(); +$environment->isDevelopment(); +$environment->isTesting(); + +// Match any one of several environments (useful for custom names). +$environment->is('production', 'staging'); diff --git a/user_guide_src/source/general/errors.rst b/user_guide_src/source/general/errors.rst index 2a79f5a33425..46bc3773f531 100644 --- a/user_guide_src/source/general/errors.rst +++ b/user_guide_src/source/general/errors.rst @@ -204,6 +204,18 @@ or when it is temporarily lost: This provides an exit code of 8. +UniqueConstraintViolationException +---------------------------------- + +.. versionadded:: 4.8.0 + +``UniqueConstraintViolationException`` extends ``DatabaseException`` and is thrown when a query +fails due to a duplicate key or unique constraint violation. It is supported by all database drivers. + +.. literalinclude:: errors/019.php + +See :ref:`database-unique-constraint-violation` for full usage details. + RedirectException ----------------- diff --git a/user_guide_src/source/general/errors/019.php b/user_guide_src/source/general/errors/019.php new file mode 100644 index 000000000000..d30592009bfe --- /dev/null +++ b/user_guide_src/source/general/errors/019.php @@ -0,0 +1,9 @@ +table('users')->insert(['email' => 'duplicate@example.com']); +} catch (UniqueConstraintViolationException $e) { + // Handle duplicate key violation +} diff --git a/user_guide_src/source/general/index.rst b/user_guide_src/source/general/index.rst index b6f148ca77d4..ca14915a39b4 100644 --- a/user_guide_src/source/general/index.rst +++ b/user_guide_src/source/general/index.rst @@ -12,6 +12,7 @@ General Topics logging errors caching + context ajax modules managing_apps diff --git a/user_guide_src/source/general/logging.rst b/user_guide_src/source/general/logging.rst index 6e159827b1e9..6d241951adf7 100644 --- a/user_guide_src/source/general/logging.rst +++ b/user_guide_src/source/general/logging.rst @@ -118,6 +118,70 @@ Several core placeholders exist that will be automatically expanded for you base | {env:foo} | The value of 'foo' in $_ENV | +----------------+---------------------------------------------------+ +.. _logging-global-context: + +Global Context Logging +---------------------- + +.. versionadded:: 4.8.0 + +You can automatically append context data to all log messages by enabling the ``$logGlobalContext`` +property in **app/Config/Logger.php**: + +.. literalinclude:: context/023.php + +When enabled, all regular context data (set via the :ref:`Context class `) is automatically +appended to every log message as a JSON string: + +.. literalinclude:: context/023.php + +This would produce a log entry like: + +.. code-block:: text + + ERROR - 2026-02-18 --> Payment processing failed {"user_id":123,"transaction_id":"txn_12345"} + +.. note:: Hidden data set with ``setHidden()`` are **never** included in log output, even when + ``$logGlobalContext`` is enabled. This protects sensitive information such as API keys + and tokens from appearing in log files. + +See :ref:`context` for full documentation on storing and managing context data. + +.. _logging-per-call-context: + +Per-Call Context Logging +------------------------ + +.. versionadded:: 4.8.0 + +By default, context values passed to ``log_message()`` are only used for placeholder +interpolation and are not stored anywhere. You can enable structured context logging +by setting ``$logContext = true`` in **app/Config/Logger.php**: + +.. literalinclude:: logging/009.php + +When enabled, any context key that is **not** referenced as a ``{placeholder}`` in the +message is passed to handlers as structured data. Keys that were interpolated into the +message are stripped by default (since their values are already present in the message +text), but you can keep them by setting ``$logContextUsedKeys = true``. + +.. literalinclude:: logging/007.php + +**Throwable normalization** + +Per PSR-3, a ``Throwable`` instance must be passed under the ``exception`` key to be +handled specially. When found there, it is automatically normalized into a meaningful +array instead of being serialized as an empty object: + +.. literalinclude:: logging/008.php + +The normalized array contains ``class``, ``message``, ``code``, ``file``, and ``line``. +To also include the full stack trace, set ``$logContextTrace = true``. + +.. note:: ``$logContext`` and ``$logGlobalContext`` are independent. You can enable either + or both. When both are enabled, per-call context and global context are merged before + being passed to handlers. + Using Third-Party Loggers ========================= diff --git a/user_guide_src/source/general/logging/007.php b/user_guide_src/source/general/logging/007.php new file mode 100644 index 000000000000..4d31e3e8bcae --- /dev/null +++ b/user_guide_src/source/general/logging/007.php @@ -0,0 +1,10 @@ + 'ord_999', // interpolated into the message, stripped from context by default + 'user_id' => 42, // not in message, kept and passed to handlers +]); + +// Handlers receive context: ['user_id' => 42] diff --git a/user_guide_src/source/general/logging/008.php b/user_guide_src/source/general/logging/008.php new file mode 100644 index 000000000000..fd36c6b72121 --- /dev/null +++ b/user_guide_src/source/general/logging/008.php @@ -0,0 +1,20 @@ + $e]); +} + +// Handlers receive context: +// [ +// 'exception' => [ +// 'class' => 'RuntimeException', +// 'message' => 'Something went wrong', +// 'code' => 0, +// 'file' => 'app/Controllers/Payment.php', +// 'line' => 42, +// ] +// ] diff --git a/user_guide_src/source/general/logging/009.php b/user_guide_src/source/general/logging/009.php new file mode 100644 index 000000000000..f4d1c9575a33 --- /dev/null +++ b/user_guide_src/source/general/logging/009.php @@ -0,0 +1,17 @@ +`. + To contribute to *nested* configuration (filters, permission matrices, and so on) + without clobbering existing values, use the + :ref:`merge directives `. .. note:: Prior to v4.4.0, ``config()`` finds the file in **app/Config/** when there is a class with the same shortname, diff --git a/user_guide_src/source/helpers/array_helper.rst b/user_guide_src/source/helpers/array_helper.rst index 8a9c795d335f..8d859de077a1 100644 --- a/user_guide_src/source/helpers/array_helper.rst +++ b/user_guide_src/source/helpers/array_helper.rst @@ -22,15 +22,26 @@ Available Functions The following functions are available: -.. php:function:: dot_array_search(string $search, array $values) +.. note:: Since v4.8.0, the dot-path helpers can read values from arrays or + objects, including ``Entity`` objects. This applies to + :php:func:`dot_array_search()`, :php:func:`dot_array_has()`, + :php:func:`dot_array_only()`, :php:func:`dot_array_except()`, and + :php:func:`array_group_by()`. + + :php:func:`dot_array_set()` and :php:func:`dot_array_unset()` still modify + arrays only. :php:func:`dot_array_only()` and + :php:func:`dot_array_except()` always return arrays. See their descriptions + for how object values are handled. + +.. php:function:: dot_array_search(string $search, array|object $values) :param string $search: The dot-notation string describing how to search the array - :param array $values: The array to search + :param array|object $values: The array or object to search :returns: The value found within the array, or null :rtype: mixed - This method allows you to use dot-notation to search through an array for a specific-key, - and allows the use of a the ``*`` wildcard. Given the following array: + This method allows you to use dot-notation to search through arrays and objects for a specific + key or property, and allows the use of the ``*`` wildcard. Given the following array: .. literalinclude:: array_helper/002.php :lines: 2- @@ -56,6 +67,101 @@ The following functions are available: .. note:: Prior to v4.2.0, ``dot_array_search('foo.bar.baz', ['foo' => ['bar' => 23]])`` returned ``23`` due to a bug. v4.2.0 and later returns ``null``. +.. php:function:: dot_array_has(string $search, array|object $values): bool + + :param string $search: The dot-notation string describing how to search the array + :param array|object $values: The array or object to check + :returns: ``true`` if the key exists, otherwise ``false`` + :rtype: bool + + .. versionadded:: 4.8.0 + + Checks if an array key exists using dot syntax. + This method supports wildcard ``*`` in the same way as ``dot_array_search()``. + + .. literalinclude:: array_helper/015.php + :lines: 2- + +.. php:function:: dot_array_set(array &$array, string $search, mixed $value): void + + :param array $array: The array to modify (passed by reference) + :param string $search: The dot-notation string describing where to set the value + :param mixed $value: The value to set + :rtype: void + + .. versionadded:: 4.8.0 + + Sets an array value using dot syntax. Missing path segments are created automatically. + Wildcard ``*`` is supported with the same rule as ``dot_array_has()``: + you must specify a key right after ``*``. + + .. literalinclude:: array_helper/016.php + :lines: 2- + +.. php:function:: dot_array_unset(array &$array, string $search): bool + + :param array $array: The array to modify (passed by reference) + :param string $search: The dot-notation string describing which key to remove + :returns: ``true`` if a key was removed, otherwise ``false`` + :rtype: bool + + .. versionadded:: 4.8.0 + + Removes array values using dot syntax. + Wildcard ``*`` is supported. + You can target specific keys like ``users.*.id`` or clear all keys under a path with ``user.*``. + + .. literalinclude:: array_helper/017.php + :lines: 2- + +.. php:function:: dot_array_only(array|object $array, array|string $indexes): array + + :param array|object $array: The source array or object + :param array|string $indexes: One key or a list of keys using dot notation + :returns: Nested array containing only the requested keys + :rtype: array + + .. versionadded:: 4.8.0 + + Gets only the specified keys using dot syntax while preserving nested structure. + + Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, + this method also allows wildcard at the end (for example ``user.*``). + + The result is always an array. If a selected value is an object, that object + is kept as the value. If you select a value inside an object, the returned + path is built with arrays: + + .. literalinclude:: array_helper/020.php + :lines: 2- + + .. literalinclude:: array_helper/018.php + :lines: 2- + +.. php:function:: dot_array_except(array|object $array, array|string $indexes): array + + :param array|object $array: The source array or object + :param array|string $indexes: One key or a list of keys using dot notation + :returns: Nested array with the specified keys removed + :rtype: array + + .. versionadded:: 4.8.0 + + Gets all keys except the specified ones using dot syntax. + + Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, + this method also allows wildcard at the end (for example ``user.*``). + + The result is always an array. Object values that are not changed are kept + as they are. If a key is removed from inside an object, that part of the + result is returned as an array: + + .. literalinclude:: array_helper/021.php + :lines: 2- + + .. literalinclude:: array_helper/019.php + :lines: 2- + .. php:function:: array_deep_search($key, array $array) :param mixed $key: The target key @@ -63,7 +169,8 @@ The following functions are available: :returns: The value found within the array, or null :rtype: mixed - Returns the value of an element with a key value in an array of uncertain depth + Returns the value of an element with a key value in an array of uncertain depth. + Only nested arrays are searched; object values are not traversed. .. php:function:: array_sort_by_multiple_keys(array &$array, array $sortColumns) @@ -106,7 +213,8 @@ The following functions are available: :returns: The flattened array This function flattens a multidimensional array to a single key-value array by using dots - as separators for the keys. + as separators for the keys. The source may be any ``iterable``. Only nested arrays are + flattened; object values are kept as-is, as leaf values. .. literalinclude:: array_helper/009.php :lines: 2- @@ -133,6 +241,7 @@ The following functions are available: This function allows you to group data rows together by index values. The depth of returned array equals the number of indexes passed as parameter. + Data rows may be arrays or objects, and dot syntax can read nested array keys or object properties. The example shows some data (i.e. loaded from an API) with nested arrays. diff --git a/user_guide_src/source/helpers/array_helper/015.php b/user_guide_src/source/helpers/array_helper/015.php new file mode 100644 index 000000000000..1fb143a56b56 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/015.php @@ -0,0 +1,16 @@ + [ + ['id' => 1, 'name' => 'Jane'], + ['id' => 2, 'name' => 'John'], + ], +]; + +// Returns: true (all matched users have an "id" key) +$hasIds = dot_array_has('users.*.id', $data); + +// If any user is missing "id", this would return false. + +// Returns: false +$hasEmails = dot_array_has('users.*.email', $data); diff --git a/user_guide_src/source/helpers/array_helper/016.php b/user_guide_src/source/helpers/array_helper/016.php new file mode 100644 index 000000000000..14662195e462 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/016.php @@ -0,0 +1,33 @@ + 'Jane'], + ['name' => 'John'], +]; + +dot_array_set($users, '*.active', true); + +/* +$data is now: +[ + 'user' => [ + 'profile' => [ + 'id' => 123, + 'name' => 'John', + ], + ], +] +*/ + +/* +$users is now: +[ + ['name' => 'Jane', 'active' => true], + ['name' => 'John', 'active' => true], +] +*/ diff --git a/user_guide_src/source/helpers/array_helper/017.php b/user_guide_src/source/helpers/array_helper/017.php new file mode 100644 index 000000000000..b14c086148f0 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/017.php @@ -0,0 +1,27 @@ + [ + 'profile' => [ + 'id' => 123, + 'name' => 'John', + ], + ], +]; + +// Returns: true +$removed = dot_array_unset($data, 'user.profile.id'); + +// Returns: false (path does not exist) +$removedAgain = dot_array_unset($data, 'user.profile.id'); + +$users = [ + ['id' => 1, 'name' => 'Jane'], + ['id' => 2, 'name' => 'John'], +]; + +// Returns: true (removes "id" from all user rows) +$removedIds = dot_array_unset($users, '*.id'); + +// Returns: true (clears all keys under "user") +$clearedUser = dot_array_unset($data, 'user.*'); diff --git a/user_guide_src/source/helpers/array_helper/018.php b/user_guide_src/source/helpers/array_helper/018.php new file mode 100644 index 000000000000..3672bf945383 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/018.php @@ -0,0 +1,33 @@ + [ + 'id' => 123, + 'name' => 'John', + 'email' => 'john@example.com', + ], + 'meta' => [ + 'request_id' => 'abc', + ], +]; + +$only = dot_array_only($data, ['user.id', 'meta.request_id']); +/* +$only: +[ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], +] +*/ + +$userOnly = dot_array_only($data, 'user.*'); +/* +$userOnly: +[ + 'user' => [ + 'id' => 123, + 'name' => 'John', + 'email' => 'john@example.com', + ], +] +*/ diff --git a/user_guide_src/source/helpers/array_helper/019.php b/user_guide_src/source/helpers/array_helper/019.php new file mode 100644 index 000000000000..96ab98ceebb1 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/019.php @@ -0,0 +1,33 @@ + [ + 'id' => 123, + 'name' => 'John', + 'email' => 'john@example.com', + ], + 'meta' => [ + 'request_id' => 'abc', + ], +]; + +$except = dot_array_except($data, ['user.email', 'meta.request_id']); +/* +$except: +[ + 'user' => [ + 'id' => 123, + 'name' => 'John', + ], + 'meta' => [], +] +*/ + +$clearUser = dot_array_except($data, 'user.*'); +/* +$clearUser: +[ + 'user' => [], + 'meta' => ['request_id' => 'abc'], +] +*/ diff --git a/user_guide_src/source/helpers/array_helper/020.php b/user_guide_src/source/helpers/array_helper/020.php new file mode 100644 index 000000000000..49e332fcf798 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/020.php @@ -0,0 +1,11 @@ + 123, 'name' => 'John']; + +// Selecting the object itself keeps the object as the value. +$whole = dot_array_only(['user' => $user], 'user'); +// ['user' => $user] + +// Selecting a value inside the object builds the path with arrays. +$partial = dot_array_only(['user' => $user], 'user.id'); +// ['user' => ['id' => 123]] diff --git a/user_guide_src/source/helpers/array_helper/021.php b/user_guide_src/source/helpers/array_helper/021.php new file mode 100644 index 000000000000..ff73943962cf --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/021.php @@ -0,0 +1,11 @@ + 123, 'name' => 'John']; + +// An untouched object is kept as it is. +$untouched = dot_array_except(['user' => $user], 'meta.id'); +// ['user' => $user] + +// Removing a key from inside an object returns that part as an array. +$partial = dot_array_except(['user' => $user], 'user.id'); +// ['user' => ['name' => 'John']] diff --git a/user_guide_src/source/incoming/filters.rst b/user_guide_src/source/incoming/filters.rst index 8da3e31baab1..d15df025a858 100644 --- a/user_guide_src/source/incoming/filters.rst +++ b/user_guide_src/source/incoming/filters.rst @@ -315,6 +315,7 @@ The filters bundled with CodeIgniter4 are: - ``forcehttps`` => :ref:`forcehttps` - ``pagecache`` => :doc:`PageCache <../general/caching>` - ``performance`` => :ref:`performancemetrics` +- ``requestid`` => :ref:`requestid` .. note:: The filters are executed in the order defined in the config file. However, if enabled, ``DebugToolbar`` is always executed last because it should be able to capture everything that happens in the other filters. @@ -377,3 +378,26 @@ If you want to customize the headers, extend ``CodeIgniter\Filters\SecureHeaders .. literalinclude:: filters/011.php If you want to know about secure headers, see `OWASP Secure Headers Project `_. + +.. _requestid: + +Request ID +========== + +.. versionadded:: 4.8.0 + +This filter adds a request ID to each request in context of the application. This can be useful for +debugging and logging purposes, as it allows you to trace a specific request through the application. + +Framework-generated IDs are 32-character hexadecimal strings and are unique for practical purposes, however valid incoming IDs are reused. +It is added to the request's context and can be accessed via the ``request_id`` key. + +To enable this filter, simply add/uncomment the ``requestid`` alias in the ``$required['before']`` and ``$required['after']`` array in **app/Config/Filters.php**: + +.. literalinclude:: filters/014.php + +.. note:: If the incoming request has a header named ``X-Request-ID``, the value of that header + will be used as the request ID instead of generating a new one or checking for uniqueness. + The framework does basic validation to ensure that the incoming request ID is a non-empty string, + has at most 64 characters, and contains only valid characters (alphanumeric, dot, underscore, colon, and hyphen). + If the validation fails, a new request ID will be generated as normal. This allows you to pass in your own request ID if you have one available, such as from a client or a load balancer. diff --git a/user_guide_src/source/incoming/filters/014.php b/user_guide_src/source/incoming/filters/014.php new file mode 100644 index 000000000000..5f6715d5f14d --- /dev/null +++ b/user_guide_src/source/incoming/filters/014.php @@ -0,0 +1,25 @@ + [ + 'requestid', // Request ID for each request + 'forcehttps', // Force Global Secure Requests + 'pagecache', // Web Page Caching + ], + 'after' => [ + 'pagecache', // Web Page Caching + 'requestid', // Request ID for each request + 'performance', // Performance Metrics + 'toolbar', // Debug Toolbar + ], + ]; + + // ... +} diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst new file mode 100644 index 000000000000..278703cd237e --- /dev/null +++ b/user_guide_src/source/incoming/form_requests.rst @@ -0,0 +1,267 @@ +.. _form-requests: + +############# +Form Requests +############# + +.. versionadded:: 4.8.0 + +A **FormRequest** is a dedicated class that encapsulates the validation rules, +custom error messages, and authorization logic for a single HTTP request. +Instead of writing validation code inside every controller method, you define +it once in a FormRequest class and type-hint it in the controller method +signature - the framework resolves, authorizes, and validates the request +automatically before your method body runs. + +.. contents:: + :local: + :depth: 2 + +*********************** +Creating a Form Request +*********************** + +Use the ``make:request`` Spark command to generate a new FormRequest class:: + + php spark make:request StorePostRequest + +This creates **app/Requests/StorePostRequest.php**. Fill in the ``rules()`` +method with the same field/rule pairs you would normally pass to +:ref:`validation-running`: + +.. literalinclude:: form_requests/001.php + :lines: 2- + +************************************ +Using a Form Request in a Controller +************************************ + +Type-hint the FormRequest class as a parameter of your controller method. The +framework instantiates it, runs authorization and validation, and passes the +resolved object to your method. If validation fails, the default behavior +redirects back with errors for web requests or returns a 422 JSON response for +JSON request bodies or requests that prefer ``application/json`` - the method +body is never reached. + +.. literalinclude:: form_requests/002.php + :lines: 2- + +************************ +Accessing Validated Data +************************ + +``getValidated()`` returns an array containing only the fields that were declared +in ``rules()``. Fields submitted by the client that are not covered by a rule +are silently discarded, protecting against mass-assignment. + +.. literalinclude:: form_requests/009.php + :lines: 2- + +Use ``getValidatedInput()`` when you want to read a single validated field or +check whether a validated key exists, including keys whose value is ``null``. +The input object supports dot-array syntax for nested validated data: + +.. literalinclude:: form_requests/014.php + :lines: 2- + +Typed Validated Input +===================== + +``getValidatedInput()`` returns the same validated data as a typed input object. +This complements ``getValidated()`` by making common controller values easier +to read after validation has succeeded. + +After the FormRequest has been validated, read the successful values in the +controller: + +.. literalinclude:: form_requests/015.php + :lines: 2- + +These typed methods do not replace validation rules. They only make accepted +values easier to consume in the controller. See :ref:`validation-validated-input` +for the full behavior of the typed input methods. + +Accessing Other Request Data +============================ + +For anything not covered by ``getValidated()`` - uploaded files, request headers, +the client IP address, raw input, and so on - use ``$this->request`` as usual. +It is the same :doc:`IncomingRequest ` instance that +the FormRequest uses internally: + +.. literalinclude:: form_requests/010.php + :lines: 2- + +******************************************* +Route Parameters Alongside a Form Request +******************************************* + +Required scalar route parameters come first, then the FormRequest, then any optional +scalar parameters. The framework matches URI segments to scalar parameters positionally +and injects the FormRequest wherever the type hint appears. Parameters declared with +PHP's ``...`` syntax captures all remaining URI segments. Optional scalar parameters +after the FormRequest get their default values when no URI segment is left to fill them. + +.. literalinclude:: form_requests/003.php + :lines: 2- + +************** +Closure Routes +************** + +FormRequest injection works identically in closure routes and follows the same +signature shape: route parameters first, then the FormRequest, then optional +scalar parameters. The framework resolves it the same way it does for controller +methods. + +.. literalinclude:: form_requests/011.php + :lines: 2- + +******************** +``_remap()`` Methods +******************** + +Automatic FormRequest injection is **not** supported for controller methods that +use ``_remap()``. Because ``_remap()`` has a fixed signature +``($method, ...$params)``, there is no typed position for the framework to +inject a FormRequest into. + +Instantiate the FormRequest manually inside ``_remap()`` and call +``resolveRequest()`` yourself. The method returns ``null`` on success or a +``ResponseInterface`` when authorization or validation fails: + +.. literalinclude:: form_requests/012.php + :lines: 2- + +********************* +Custom Error Messages +********************* + +Override ``messages()`` to return field-specific error messages. The format is +identical to the ``$errors`` argument of :ref:`saving-validation-rules-to-config-file`: + +.. literalinclude:: form_requests/004.php + :lines: 2- + +************* +Authorization +************* + +Override ``isAuthorized()`` to control whether the current user is allowed to make +this request. Return ``false`` to reject the request with a 403 Forbidden +response before validation even runs. + +.. literalinclude:: form_requests/005.php + :lines: 2- + +.. note:: The ``isAuthorized()`` check runs before ``prepareForValidation()`` and + before validation itself. An unauthorized request never reaches the + validation stage. + +********************************* +Preparing Data Before Validation +********************************* + +Override ``prepareForValidation(array $data): array`` to normalize or derive +input values before the validation rules are applied. The method receives the +same data array that will be passed to the validator and must return the +(possibly modified) array. This is useful for computed fields such as slugs, +normalized phone numbers, or trimmed strings. + +.. literalinclude:: form_requests/006.php + :lines: 2- + +.. note:: When validation fails and the default redirect response is used, + ``old()`` returns the prepared validation data. Use ``getValidated()`` to + access the processed data after a successful request. + +.. _form-request-validation-data: + +**************************** +Customizing the Data Source +**************************** + +By default ``validationData()`` selects the appropriate data source +automatically based on the HTTP method and ``Content-Type`` header: + +* **JSON request** (``Content-Type: application/json``) -> decoded JSON body +* **PUT / PATCH / DELETE** (non-multipart) -> raw body via ``getRawInput()`` +* **GET / HEAD** -> query-string parameters via ``getGet()`` +* **Everything else** (POST, multipart) -> POST body via ``getPost()`` + +This avoids the pitfalls of :ref:`validation-withrequest`, which mixes GET and +POST data via ``getVar()``. + +Override ``validationData()`` when you need a different data source - for +example, to merge GET and POST parameters: + +.. literalinclude:: form_requests/007.php + :lines: 2- + +**************************** +Customizing Failure Behavior +**************************** + +Override ``failedValidation()`` and ``failedAuthorization()`` to take full +control of what happens when a request is rejected. Both methods return a +``ResponseInterface`` that the framework sends to the client: + +.. literalinclude:: form_requests/008.php + :lines: 2- + +The default ``failedValidation()`` returns a 422 JSON response for JSON request +bodies or requests that prefer ``application/json`` through the ``Accept`` +header. Otherwise, it redirects back with input and validation errors. + +.. note:: The ``X-Requested-With: XMLHttpRequest`` header alone does not select + a JSON response. If an AJAX client expects JSON validation errors, send an + ``Accept: application/json`` header. If your application needs HTML + fragments for AJAX form failures, override ``failedValidation()``. + +.. _form-request-flash-normalized: + +Flashing Normalized Input +========================= + +If your ``prepareForValidation()`` transforms visible form fields (for example, +trimming strings or canonicalizing values), the default redirect response flashes +the prepared validation data as old input. + +If you override ``failedValidation()`` and still need to flash normalized input, +use the second ``$preparedData`` argument. It contains the same data that was +passed to validation: + +.. literalinclude:: form_requests/013.php + :lines: 2- + +The prepared data has not passed validation. After successful validation, use +``getValidated()`` or ``getValidatedInput()`` for trusted values. + +***************************************** +How the Framework Resolves Form Requests +***************************************** + +When the router dispatches a controller method or closure route, the framework +inspects the callable's parameter list using reflection. For each parameter +whose type extends ``FormRequest``: + +#. A new instance is created with the current request injected via the + constructor. +#. ``isAuthorized()`` is called. If it returns ``false``, ``failedAuthorization()`` + is called, and its response is returned to the client. +#. ``validationData()`` collects the data to validate. +#. ``prepareForValidation()`` receives that data and may modify it before the + rules are applied. +#. ``run()`` executes the validation rules. If it fails, ``failedValidation()`` + is called, and its response is returned to the client. +#. The validated data is stored internally and available via ``getValidated()`` + and ``getValidatedInput()``. +#. The resolved FormRequest object is injected into the controller method or + closure. + +The callable is never invoked if authorization or validation fails. Non-FormRequest +parameters consume URI route segments in declaration order; variadic parameters +receive all remaining segments. + +.. note:: Automatic injection does not apply to ``_remap()`` methods. See + `_remap() Methods`_ above for the manual workaround. diff --git a/user_guide_src/source/incoming/form_requests/001.php b/user_guide_src/source/incoming/form_requests/001.php new file mode 100644 index 000000000000..d8bf7bf464eb --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/001.php @@ -0,0 +1,16 @@ + 'required|min_length[3]|max_length[255]', + 'body' => 'required', + ]; + } +} diff --git a/user_guide_src/source/incoming/form_requests/002.php b/user_guide_src/source/incoming/form_requests/002.php new file mode 100644 index 000000000000..0e9ba8baba2b --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/002.php @@ -0,0 +1,18 @@ +getValidated() returns only the fields declared in rules(). + $data = $request->getValidated(); + + // save to database + + return redirect()->to('/posts'); + } +} diff --git a/user_guide_src/source/incoming/form_requests/003.php b/user_guide_src/source/incoming/form_requests/003.php new file mode 100644 index 000000000000..3d7015c865bd --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/003.php @@ -0,0 +1,18 @@ +getValidated(); + + // update post $id with $data + + return redirect()->to('/posts/' . $id); + } +} diff --git a/user_guide_src/source/incoming/form_requests/004.php b/user_guide_src/source/incoming/form_requests/004.php new file mode 100644 index 000000000000..96f4f2a8fbc2 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/004.php @@ -0,0 +1,29 @@ + 'required|min_length[3]|max_length[255]', + 'body' => 'required', + ]; + } + + public function messages(): array + { + return [ + 'title' => [ + 'required' => 'Post title cannot be empty.', + 'min_length' => 'Post title must be at least {param} characters long.', + ], + 'body' => [ + 'required' => 'Post body cannot be empty.', + ], + ]; + } +} diff --git a/user_guide_src/source/incoming/form_requests/005.php b/user_guide_src/source/incoming/form_requests/005.php new file mode 100644 index 000000000000..8b6cdd0d1ab9 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/005.php @@ -0,0 +1,22 @@ + 'required|min_length[3]', + 'body' => 'required', + ]; + } + + public function isAuthorized(): bool + { + // Only authenticated users may submit posts. + return auth()->loggedIn(); + } +} diff --git a/user_guide_src/source/incoming/form_requests/006.php b/user_guide_src/source/incoming/form_requests/006.php new file mode 100644 index 000000000000..f28f8f2a07a5 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/006.php @@ -0,0 +1,25 @@ + 'required|min_length[3]', + 'slug' => 'required|is_unique[posts.slug]', + ]; + } + + protected function prepareForValidation(array $data): array + { + // Derive the slug from the title so it is available for the + // is_unique rule before validation runs. + $data['slug'] = url_title($data['title'] ?? '', '-', true); + + return $data; + } +} diff --git a/user_guide_src/source/incoming/form_requests/007.php b/user_guide_src/source/incoming/form_requests/007.php new file mode 100644 index 000000000000..806765941576 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/007.php @@ -0,0 +1,22 @@ + 'required|min_length[2]']; + } + + // Merge query-string parameters and POST body for this endpoint. + protected function validationData(): array + { + return array_merge( + $this->request->getGet() ?? [], + $this->request->getPost() ?? [], + ); + } +} diff --git a/user_guide_src/source/incoming/form_requests/008.php b/user_guide_src/source/incoming/form_requests/008.php new file mode 100644 index 000000000000..98103e8fef7e --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/008.php @@ -0,0 +1,29 @@ + 'required|min_length[3]', + 'body' => 'required', + ]; + } + + // Always respond with JSON, regardless of the request type. + protected function failedValidation(array $errors, array $preparedData): ResponseInterface + { + return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); + } + + // Always respond with 403, regardless of the request type. + protected function failedAuthorization(): ResponseInterface + { + return service('response')->setStatusCode(403); + } +} diff --git a/user_guide_src/source/incoming/form_requests/009.php b/user_guide_src/source/incoming/form_requests/009.php new file mode 100644 index 000000000000..6df129fb7705 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/009.php @@ -0,0 +1,4 @@ +getValidated(); +// ['title' => 'My post title', 'body' => 'Body text'] diff --git a/user_guide_src/source/incoming/form_requests/010.php b/user_guide_src/source/incoming/form_requests/010.php new file mode 100644 index 000000000000..7fd1bf365507 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/010.php @@ -0,0 +1,16 @@ +getValidated(); + $files = $this->request->getFiles(); + + // ... + } +} diff --git a/user_guide_src/source/incoming/form_requests/011.php b/user_guide_src/source/incoming/form_requests/011.php new file mode 100644 index 000000000000..b482c5f245f5 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/011.php @@ -0,0 +1,24 @@ +post('posts', static function (StorePostRequest $request): string { + $data = $request->getValidated(); + + // save to database + + return redirect()->to('/posts'); +}); + +// Route parameter before a FormRequest - same convention as controller methods. +$routes->post('posts/(:num)', static function (int $id, UpdatePostRequest $request): string { + $data = $request->getValidated(); + + // update post $id with $data + + return redirect()->to('/posts/' . $id); +}); diff --git a/user_guide_src/source/incoming/form_requests/012.php b/user_guide_src/source/incoming/form_requests/012.php new file mode 100644 index 000000000000..42732295dd77 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/012.php @@ -0,0 +1,37 @@ +resolveRequest(); + + if ($response instanceof ResponseInterface) { + // Authorization or validation failed - send the response. + return $response; + } + + return $this->update($request, ...$params); + } + + return $this->{$method}(...$params); + } + + private function update(UpdatePostRequest $request, string $id): string + { + $data = $request->getValidated(); + + // update post $id with $data + + return redirect()->to('/posts/' . $id); + } +} diff --git a/user_guide_src/source/incoming/form_requests/013.php b/user_guide_src/source/incoming/form_requests/013.php new file mode 100644 index 000000000000..041164fd43b7 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/013.php @@ -0,0 +1,46 @@ + 'required|min_length[3]', + 'slug' => 'required|is_unique[posts.slug]', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['slug'] = url_title($data['title'] ?? '', '-', true); + + return $data; + } + + // Override while still flashing the prepared values on redirect. + protected function failedValidation(array $errors, array $preparedData): ResponseInterface + { + if ( + $this->request->is('json') + || $this->request->negotiate('media', ['text/html', 'application/json'], true) === 'application/json' + ) { + return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); + } + + // withInput() flashes validation errors. Then we replace old input with + // the same prepared values that were passed to validation. + $redirect = redirect()->back()->withInput(); + + service('session')->setFlashdata('_ci_old_input', [ + 'get' => [], + 'post' => $preparedData, + ]); + + return $redirect; + } +} diff --git a/user_guide_src/source/incoming/form_requests/014.php b/user_guide_src/source/incoming/form_requests/014.php new file mode 100644 index 000000000000..ca2a733e563a --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/014.php @@ -0,0 +1,10 @@ +getValidatedInput(); + +$title = $input->get('title'); +$slug = $input->get('post.meta.slug', 'draft'); + +if ($input->has('note')) { + // 'note' was validated, even if its value is null +} diff --git a/user_guide_src/source/incoming/form_requests/015.php b/user_guide_src/source/incoming/form_requests/015.php new file mode 100644 index 000000000000..8010bbf7107c --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/015.php @@ -0,0 +1,11 @@ +getValidatedInput(); + +$page = $input->integer('page', 1); +$rating = $input->float('rating', 0.0); +$active = $input->boolean('active', false); +$publishedAt = $input->date('published_at', 'Y-m-d'); +$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT); diff --git a/user_guide_src/source/incoming/incomingrequest.rst b/user_guide_src/source/incoming/incomingrequest.rst index a96353dadfba..680f0b807dce 100644 --- a/user_guide_src/source/incoming/incomingrequest.rst +++ b/user_guide_src/source/incoming/incomingrequest.rst @@ -161,6 +161,30 @@ The ``getVar()`` method will pull from ``$_REQUEST``, so will return any data fr .. note:: If the incoming request has a ``Content-Type`` header set to ``application/json``, the ``getVar()`` method returns the JSON data instead of ``$_REQUEST`` data. +.. _incomingrequest-typed-request-input: + +Typed Request Input +=================== + +.. versionadded:: 4.8.0 + +``input()`` returns a ``CodeIgniter\HTTP\RequestInput`` object. Use it to read +values from a specific part of the request with typed fallback helpers: + +.. literalinclude:: incomingrequest/046.php + :lines: 2- + +``input()->get()`` reads query-string parameters. ``input()->post()`` reads +POST body parameters. ``input()->json()`` reads JSON request body parameters. +``input()->raw()`` reads raw input parameters, like ``getRawInput()``. + +These methods keep GET, POST, JSON, and raw data separate. They do not combine +multiple request sources for you. + +These methods do not validate input. They are fallback-friendly helpers for +reading raw request data. Use Validation or :ref:`form-requests` when input +must satisfy application rules before it is consumed. + .. _incomingrequest-getting-json-data: Getting JSON Data @@ -227,6 +251,7 @@ Filtering a POST variable would look like this: All of the methods mentioned above support the filter type passed in as the second parameter, with the exception of ``getJSON()`` and ``getRawInput()``. +The typed input helpers returned by ``input()`` do not accept filter parameters. Retrieving Headers ****************** @@ -406,6 +431,11 @@ The methods provided by the parent classes that are available are: .. literalinclude:: incomingrequest/045.php + .. php:method:: input() + + :returns: A typed input data selector. + :rtype: CodeIgniter\\HTTP\\RequestInput + .. php:method:: getPost([$index = null[, $filter = null[, $flags = null]]]) :param string $index: The name of the variable/key to look for. @@ -519,4 +549,3 @@ The methods provided by the parent classes that are available are: .. note:: Prior to v4.4.0, this was the safest method to determine the "current URI", since ``IncomingRequest::$uri`` might not be aware of the complete App configuration for base URLs. - diff --git a/user_guide_src/source/incoming/incomingrequest/046.php b/user_guide_src/source/incoming/incomingrequest/046.php new file mode 100644 index 000000000000..d0c6aca18158 --- /dev/null +++ b/user_guide_src/source/incoming/incomingrequest/046.php @@ -0,0 +1,6 @@ +input()->get()->integer('page', 1); +$remember = $request->input()->post()->boolean('remember', false); +$name = $request->input()->json()->string('name'); +$published = $request->input()->raw()->boolean('published', false); diff --git a/user_guide_src/source/incoming/index.rst b/user_guide_src/source/incoming/index.rst index ebcc316e10ee..0a2f518b20c3 100644 --- a/user_guide_src/source/incoming/index.rst +++ b/user_guide_src/source/incoming/index.rst @@ -15,6 +15,7 @@ Controllers handle incoming requests. message request incomingrequest + form_requests content_negotiation methodspoofing restful diff --git a/user_guide_src/source/incoming/request.rst b/user_guide_src/source/incoming/request.rst index 64c427f1b0d3..45875584e546 100644 --- a/user_guide_src/source/incoming/request.rst +++ b/user_guide_src/source/incoming/request.rst @@ -35,28 +35,6 @@ Class Reference .. important:: This method takes into account the ``Config\App::$proxyIPs`` setting and will return the reported client IP address by the HTTP header for the allowed IP address. - .. php:method:: isValidIP($ip[, $which = '']) - - .. deprecated:: 4.0.5 - Use :doc:`../libraries/validation` instead. - - .. important:: This method is deprecated. It will be removed in future releases. - - :param string $ip: IP address - :param string $which: IP protocol (``ipv4`` or ``ipv6``) - :returns: true if the address is valid, false if not - :rtype: bool - - Takes an IP address as input and returns true or false (boolean) depending - on whether it is valid or not. - - .. note:: The $request->getIPAddress() method above automatically validates the IP address. - - .. literalinclude:: request/002.php - - Accepts an optional second string parameter of ``ipv4`` or ``ipv6`` to specify - an IP format. The default checks for both formats. - .. php:method:: getMethod() :returns: HTTP request method @@ -101,27 +79,6 @@ Class Reference .. literalinclude:: request/005.php - .. php:method:: getEnv([$index = null[, $filter = null[, $flags = null]]]) - - .. deprecated:: 4.4.4 This method does not work from the beginning. Use - :php:func:`env()` instead. - - :param mixed $index: Value name - :param int $filter: The type of filter to apply. A list of filters can be found in `PHP manual `__. - :param int|array $flags: Flags to apply. A list of flags can be found in `PHP manual `__. - :returns: ``$_ENV`` item value if found, null if not - :rtype: mixed - - This method is identical to the ``getPost()``, ``getGet()`` and ``getCookie()`` methods from the - :doc:`IncomingRequest Class <./incomingrequest>`, only it fetches env data (``$_ENV``): - - .. literalinclude:: request/006.php - - To return an array of multiple ``$_ENV`` values, pass all the required keys - as an array. - - .. literalinclude:: request/007.php - .. php:method:: setGlobal($method, $value) :param string $method: Method name diff --git a/user_guide_src/source/incoming/request/002.php b/user_guide_src/source/incoming/request/002.php deleted file mode 100644 index 4b2e39489f72..000000000000 --- a/user_guide_src/source/incoming/request/002.php +++ /dev/null @@ -1,7 +0,0 @@ -isValidIP($ip)) { - echo 'Not Valid'; -} else { - echo 'Valid'; -} diff --git a/user_guide_src/source/incoming/request/006.php b/user_guide_src/source/incoming/request/006.php deleted file mode 100644 index b503574b79cc..000000000000 --- a/user_guide_src/source/incoming/request/006.php +++ /dev/null @@ -1,3 +0,0 @@ -getEnv('some_data'); diff --git a/user_guide_src/source/incoming/request/007.php b/user_guide_src/source/incoming/request/007.php deleted file mode 100644 index aa1ad285e0bd..000000000000 --- a/user_guide_src/source/incoming/request/007.php +++ /dev/null @@ -1,3 +0,0 @@ -getEnv(['CI_ENVIRONMENT', 'S3_BUCKET']); diff --git a/user_guide_src/source/incoming/routing.rst b/user_guide_src/source/incoming/routing.rst index 7dda8b687131..0aad82af632c 100644 --- a/user_guide_src/source/incoming/routing.rst +++ b/user_guide_src/source/incoming/routing.rst @@ -237,6 +237,35 @@ This must be called before you add the route: .. literalinclude:: routing/017.php +.. _placeholder-samples-for-spark-routes: + +Sample Values for ``spark routes`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: 4.8.0 + +The :doc:`spark routes ` command needs a concrete URI that matches each route in order +to resolve the filters that would run for it. For the built-in placeholders (``num``, ``segment``, +``alpha``, and so on) it uses a hardcoded sample value. For custom placeholders it now attempts to generate +a value from the placeholder's regular expression, so common patterns like ``[A-Z]{3}[0-9]+`` or the UUID +example above work without any extra configuration. + +If the regex uses features the generator cannot reverse (lookarounds, backreferences, complex alternations, +etc.), the Before/After Filters columns fall back to showing ````. You can fix that by providing an +explicit sample in ``app/Config/Routing.php``: + +.. literalinclude:: routing/075.php + +Each value must match its placeholder's regular expression. A value that does not match is ignored. Entries +override the samples for the built-in placeholders too, not only custom ones. + +The resolution order is: a matching value declared in ``$placeholderSamples``, then the built-in sample for +standard placeholders, then an auto-generated value, then ```` as a last resort. + +.. note:: This configuration only affects the ``spark routes`` display. + Filter execution at request time already honors custom placeholders + without any setup. + Regular Expressions ------------------- diff --git a/user_guide_src/source/incoming/routing/075.php b/user_guide_src/source/incoming/routing/075.php new file mode 100644 index 000000000000..d309d52aba58 --- /dev/null +++ b/user_guide_src/source/incoming/routing/075.php @@ -0,0 +1,13 @@ + 'ABC123', + 'uuid' => '550e8400-e29b-41d4-a716-446655440000', + ]; +} diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst new file mode 100644 index 000000000000..25591bf7921b --- /dev/null +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -0,0 +1,103 @@ +############################# +Upgrading from 4.7.x to 4.8.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 +**************** + +Console Exit Codes +================== + +Previously, returning a non-integer value from a command run through ``spark`` would be treated as a successful execution (exit code ``0``). +Starting with v4.8.0, this behavior is still supported but will trigger a deprecation notice. Commands should now return an integer exit code +to ensure proper behavior across all platforms. + +********************* +Breaking Enhancements +********************* + +Log Handler Interface +===================== + +``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now accepts a third +parameter ``array $context = []``. + +If you have a custom log handler that overrides the ``handle()`` method +(whether implementing ``HandlerInterface`` directly or extending a built-in +handler class), you must update your ``handle()`` method signature: + +.. code-block:: php + + // Before + public function handle($level, $message): bool + + // After + public function handle($level, $message, array $context = []): bool + +The context array may contain the CI global context data under the +``HandlerInterface::GLOBAL_CONTEXT_KEY`` (``'_ci_context'``) key when +``$logGlobalContext`` is enabled in ``Config\Logger``. + +************* +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 +------ + +- app/Config/Filters.php + - Added a new filter named ``requestid`` that adds a unique request ID to each request in the application's context. +- app/Config/Mimes.php + - ``Config\Mimes::$mimes`` added a new key ``md`` for Markdown files. +- app/Config/Routing.php + - ``Config\Routing::$placeholderSamples`` was added to provide sample values for custom route placeholders so the ``spark routes`` command can resolve their filters. +- app/Config/Security.php + - ``Config\Security::$csrfFetchMetadata`` and ``Config\Security::$csrfFetchMetadataRejectSameSite`` were added for Fetch Metadata based CSRF protection. + +Error Views +----------- + +- app/Views/errors/html/debug.css + - Added styles for the **Copy Details** button. +- app/Views/errors/html/debug.js + - Added clipboard handling for the **Copy Details** button. +- app/Views/errors/html/error_exception.php + - Added a **Copy Details** button to detailed HTML exception pages. +- app/Views/errors/html/error_report.php + - Added a Markdown error report partial used by the **Copy Details** button. + +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 dc9199da84ca..5cdba28b9758 100644 --- a/user_guide_src/source/installation/upgrading.rst +++ b/user_guide_src/source/installation/upgrading.rst @@ -22,6 +22,7 @@ Alternatively, replace it with a new file and add your previous lines. backward_compatibility_notes + upgrade_480 upgrade_474 upgrade_473 upgrade_472 diff --git a/user_guide_src/source/libraries/caching.rst b/user_guide_src/source/libraries/caching.rst index b208d63499df..6a4184ea10c6 100644 --- a/user_guide_src/source/libraries/caching.rst +++ b/user_guide_src/source/libraries/caching.rst @@ -128,10 +128,10 @@ Class Reference .. literalinclude:: caching/003.php - .. php:method:: remember(string $key, int $ttl, Closure $callback) + .. php:method:: remember(string $key, callable|int $ttl, Closure $callback) :param string $key: Cache item name - :param int $ttl: Time to live in seconds + :param callable|int $ttl: Time to live in seconds :param Closure $callback: Callback to invoke when the cache item returns null :returns: The value of the cache item :rtype: mixed @@ -139,6 +139,29 @@ Class Reference Gets an item from the cache. If ``null`` was returned, this will invoke the callback and save the result. Either way, this will return the value. + The ``$ttl`` parameter may also be a callable, allowing the TTL to be + determined dynamically at runtime. This is especially useful when the + expiration time depends on the computed value or requires an expensive + calculation. + + When a callable is provided, it will only be executed on a cache miss, + after the callback has been invoked. The callable always receives the + computed value as its first argument: + + .. literalinclude:: caching/015.php + + This ensures that TTL computation is deferred until necessary and avoids + unnecessary overhead when the cache item already exists. + + .. note:: Prior to v4.8.0, the second parameter only accepted an integer TTL value. The ability to pass a callable was added in v4.8.0. + + .. note:: When using the APCu cache handler, providing a callable TTL disables + the use of ``apcu_entry()`` and falls back to a manual cache retrieval + and storage process. As a result, the operation is no longer atomic + and may be subject to race conditions under high concurrency. + + If atomic behavior is required, use an integer TTL value. + .. php:method:: save(string $key, $data[, int $ttl = 60]) :param string $key: Cache item name @@ -276,7 +299,7 @@ Drivers APCu Caching ============ -APCu is an in-memory key-value store for PHP. +APCu is an in-memory key-value store for PHP. To use it, you need the `APCu PHP extension `_. diff --git a/user_guide_src/source/libraries/caching/015.php b/user_guide_src/source/libraries/caching/015.php new file mode 100644 index 000000000000..4e4a86aeb4d0 --- /dev/null +++ b/user_guide_src/source/libraries/caching/015.php @@ -0,0 +1,11 @@ +remember('key', static fn ($value) => 60, static fn () => fetchData()); + +// Value-aware TTL +$cache->remember( + 'key', + static fn ($value) => $value->expires_at - time(), + static fn () => fetchData(), +); diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index c1bbc4eccdfd..61e73e702796 100644 --- a/user_guide_src/source/libraries/curlrequest.rst +++ b/user_guide_src/source/libraries/curlrequest.rst @@ -374,6 +374,60 @@ You can pass along data to send as query string variables by passing an associat .. literalinclude:: curlrequest/029.php +.. _curlrequest-request-options-retry: + +retry +===== + +.. versionadded:: 4.8.0 + +The ``retry`` option retries failed requests before returning the final response or throwing an exception. +For simple cases, set ``retry`` to the number of retry attempts: + +.. code-block:: php + + $response = $client->request('GET', 'https://api.example.com/items', [ + 'retry' => 3, + ]); + +For more control, pass an array: + +.. literalinclude:: curlrequest/041.php + +The available retry settings are: + +- ``max_retries``: Number of retries after the initial request. The default is ``3``. +- ``delay``: Delay in milliseconds before retrying. This may be an integer or a list of integers + for simple backoff. If the list is shorter than the retry count, the last value is reused. + The default is ``1000``. +- ``max_delay``: Maximum delay in milliseconds. This caps both the configured ``delay`` and a + valid ``Retry-After`` header. Set to ``0`` for no maximum delay. The default is ``30000``. +- ``status_codes``: HTTP status codes that should be retried. The default is ``[429, 503, 504]``. +- ``curl_errors``: Whether to retry transient cURL errors. The default is ``false``. +- ``respect_retry_after``: Whether to use a valid ``Retry-After`` header instead of the configured + ``delay``. The default is ``true``. + +When ``respect_retry_after`` is enabled, a valid ``Retry-After`` header takes priority over the +configured ``delay``. The header may be either a number of seconds or an HTTP date. If the header +is invalid, the configured ``delay`` is used instead. + +Because ``Retry-After`` is supplied by the remote server, it is capped by ``max_delay`` by default +to avoid unexpectedly long waits in the current PHP process. + +When ``curl_errors`` is enabled, only DNS resolution failures, connection failures, timeouts, +and send or receive failures are retried. + +When `http_errors`_ is enabled, an HTTP error response is retried first if its status code is +configured in ``status_codes``. If all retry attempts are exhausted, the final HTTP error response +throws the same as a request without retries. + +.. note:: Retry delays block the current PHP process. The total request time may exceed the + configured `timeout`_ because each retry attempt has its own cURL timeout and retry delays + are added between attempts. + +.. warning:: Be careful when retrying non-idempotent requests such as ``POST`` or ``PATCH``. + The remote server may receive the request more than once. + timeout ======= diff --git a/user_guide_src/source/libraries/curlrequest/041.php b/user_guide_src/source/libraries/curlrequest/041.php new file mode 100644 index 000000000000..2044c4f31f98 --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/041.php @@ -0,0 +1,12 @@ +request('GET', 'https://api.example.com/items', [ + 'retry' => [ + 'max_retries' => 3, + 'delay' => [100, 500, 1000], + 'max_delay' => 5000, + 'status_codes' => [429, 500, 502, 503, 504], + 'curl_errors' => true, + 'respect_retry_after' => true, + ], +]); diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index bbdfa1e053c9..3d9da139dce1 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -226,6 +226,36 @@ Key Rotation Workflow 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. +.. _spark-key-rotate: + +Rotating with the ``key:rotate`` Command +---------------------------------------- + +.. versionadded:: 4.8.0 + +Step 2 above (demoting the current ``key`` and generating a new one) can be performed with the +``key:rotate`` spark command, which edits the **.env** file in place:: + + php spark key:rotate + +The command reads ``encryption.key`` from your environment, prepends it to +``encryption.previousKeys`` (newest first, deduplicated), and writes a fresh ``encryption.key``. +Useful options: + +- ``--prefix`` (``hex2bin`` or ``base64``, default ``hex2bin``) and ``--length`` (positive + integer, default ``32``) control how the new key is generated, mirroring ``key:generate``. +- ``--keep=N`` caps the retained ``previousKeys`` list to the ``N`` most recent entries. ``N`` must + be a non-negative integer; ``0`` (the default) keeps every previous key. +- ``--force`` / ``-f`` skips the interactive confirmation. Required when running with + ``--no-interaction``. + +All three options are validated up-front, so an invalid value cannot leave the **.env** file +half-rotated. + +.. warning:: ``key:rotate`` is not safe under concurrent execution. The command edits the + **.env** file without taking a file lock, so two operators (or two automation runs) + rotating at the same time can lose rotated-out keys. + Padding ======= diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index 2bffd2b9a122..3675f8e58d7c 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -95,7 +95,9 @@ Image Quality ``save()`` can take an additional parameter ``$quality`` to alter the resulting image quality. Values range from 0 to 100 with 90 being the framework default. This parameter -only applies to JPEG and WebP images, will be ignored otherwise: +only applies to JPEG, WebP and AVIF images, will be ignored otherwise: + +.. note:: For AVIF images, it is suggested to set the ``$quality`` parameter to 52. See also https://www.php.net/manual/en/function.imageavif.php .. note:: The parameter ``$quality`` for WebP can be used since v4.4.0. diff --git a/user_guide_src/source/libraries/index.rst b/user_guide_src/source/libraries/index.rst index a67b4d97e545..0445001977a0 100644 --- a/user_guide_src/source/libraries/index.rst +++ b/user_guide_src/source/libraries/index.rst @@ -15,6 +15,7 @@ Library Reference file_collections honeypot images + locks pagination publisher security diff --git a/user_guide_src/source/libraries/locks.rst b/user_guide_src/source/libraries/locks.rst new file mode 100644 index 000000000000..0af1b9c7e73b --- /dev/null +++ b/user_guide_src/source/libraries/locks.rst @@ -0,0 +1,193 @@ +############ +Atomic Locks +############ + +.. versionadded:: 4.8.0 + +.. contents:: + :local: + :depth: 2 + +Atomic locks provide a simple way to prevent the same task from running +concurrently across requests, CLI commands, or workers that share the same +cache storage. + +Locks are advisory. Your code must acquire the lock before entering the +critical section, and release it when the work is finished. + +************* +Configuration +************* + +The Locks library uses the Cache service. The cache handler must support +owner-aware lock operations. The built-in **File**, **Redis**, and **Predis** +cache handlers support atomic lock operations. The **Memcached** cache handler +also supports locks, with Memcached-specific limitations described below. + +.. note:: Locks are most useful when all competing processes share the same cache + storage. The File handler is suitable for a single server. For multiple + application servers, use a shared handler such as Redis. + +.. important:: Locks are stored in the configured cache handler. Clearing or + flushing that cache storage, for example with ``cache()->clean()`` or a + Redis ``FLUSHDB``, may remove active locks. Avoid clearing shared lock + storage while lock-protected work is running, or use a dedicated cache + store for locks when that separation is important. + +.. note:: Memcached lock support requires the ``memcached`` PHP extension. + The older ``memcache`` extension does not provide the CAS operations used + for owner-aware refresh and best-effort release. Memcached does not provide + an atomic compare-and-delete command, so releasing a Memcached lock cannot + provide the same owner-checked atomic release guarantee as Redis or Predis. + A delayed release may delete a lock that expired and was acquired by another + owner. Memcached locks may also be lost if the Memcached server restarts, + evicts keys, or flushes its cache. + +.. note:: File-backed locks clear released and expired lock contents, but may + leave empty lock files in the cache directory. These files do not represent + active locks and may be removed by normal cache cleanup when no + lock-protected work is running. + +************* +Example Usage +************* + +You can create a lock through the ``locks`` service. The second argument is the +lock TTL, in seconds. The TTL prevents abandoned locks from being held forever +if a process exits unexpectedly. + +.. literalinclude:: locks/001.php + +.. warning:: If the work takes longer than the lock TTL, another process may + acquire the same lock while the first process is still running. For + long-running work, choose a TTL that comfortably covers the operation, call + ``refresh()`` while the lock is held, or check ``isAcquired()`` before + performing irreversible side effects. + +Running a Callback +================== + +The ``run()`` method acquires the lock, runs the callback, and releases the lock +in a ``finally`` block. + +.. literalinclude:: locks/002.php + +If the lock cannot be acquired, ``run()`` returns ``false`` and the callback is +not called. + +Blocking +======== + +The ``block()`` method waits up to the given number of seconds for the lock to +become available: + +.. literalinclude:: locks/003.php + +Restoring a Lock by Owner +========================= + +Each acquired lock has an owner token. You may pass this token to another +process and restore the lock there, for example to release a lock from a queued +worker that continues work started by the current request. + +.. literalinclude:: locks/004.php + +************************ +Locks and Cache Handlers +************************ + +The default File cache handler supports locks, so locks work without additional +configuration in a standard application. + +If the configured cache handler does not support locks, resolving the ``locks`` +service or constructing a lock manager throws a +``CodeIgniter\Lock\Exceptions\LockException``. + +Custom cache handlers can support locks by implementing +``CodeIgniter\Cache\LockStoreProviderInterface`` and returning a +``CodeIgniter\Cache\LockStoreInterface`` instance. This keeps lock support +opt-in and does not require all cache handlers to implement lock operations. + +Custom lock stores must implement owner-aware acquisition, release, refresh, +force release, and owner lookup methods. ``acquireLock()`` should atomically +claim the lock for an owner token and TTL. ``releaseLock()`` and +``refreshLock()`` must only affect the lock when the supplied owner token still +matches the current owner. ``forceReleaseLock()`` intentionally ignores +ownership, and ``getLockOwner()`` should return ``null`` when the lock is absent +or expired. + +*************** +Class Reference +*************** + +.. php:namespace:: CodeIgniter\Lock + +.. php:class:: LockManager + + .. php:method:: create(string $name[, int $ttl = 300[, ?string $owner = null]]) + + :param string $name: The logical lock name. + :param int $ttl: Number of seconds before the lock expires. + :param string|null $owner: Optional owner token. + :returns: A lock instance. + :rtype: LockInterface + + Creates a lock for the given logical name. + + .. php:method:: restore(string $name, string $owner[, int $ttl = 300]) + + :param string $name: The logical lock name. + :param string $owner: The owner token. + :param int $ttl: Number of seconds before the lock expires. + :returns: A lock instance. + :rtype: LockInterface + + Restores a lock instance for an existing owner token. + +.. php:interface:: LockInterface + + .. php:method:: acquire() + + :returns: ``true`` if the lock was acquired, ``false`` otherwise. + :rtype: bool + + .. php:method:: block(int $seconds) + + :param int $seconds: Maximum number of seconds to wait. + :returns: ``true`` if the lock was acquired, ``false`` otherwise. + :rtype: bool + + .. php:method:: run(Closure $callback[, int $waitSeconds = 0]) + + :param Closure $callback: The callback to run while the lock is held. + :param int $waitSeconds: Maximum number of seconds to wait. + :returns: The callback result, or ``false`` if the lock was not acquired. + :rtype: mixed + + .. php:method:: release() + + :returns: ``true`` if the lock was released by its owner. + :rtype: bool + + .. php:method:: forceRelease() + + :returns: ``true`` if the lock was force released. + :rtype: bool + + Releases the lock without checking the owner token. + + .. php:method:: refresh([?int $ttl = null]) + + :param int|null $ttl: Number of seconds before the lock expires. + :returns: ``true`` if the owned lock was refreshed. + :rtype: bool + + .. php:method:: isAcquired() + + :returns: ``true`` if this lock instance still owns the lock. + :rtype: bool + + .. php:method:: owner() + + :returns: The owner token. + :rtype: string diff --git a/user_guide_src/source/libraries/locks/001.php b/user_guide_src/source/libraries/locks/001.php new file mode 100644 index 000000000000..f6a402d96916 --- /dev/null +++ b/user_guide_src/source/libraries/locks/001.php @@ -0,0 +1,13 @@ +create('reports.daily-export', 300); + +if (! $lock->acquire()) { + return; +} + +try { + // Run the work that must not overlap. +} finally { + $lock->release(); +} diff --git a/user_guide_src/source/libraries/locks/002.php b/user_guide_src/source/libraries/locks/002.php new file mode 100644 index 000000000000..b120e8ca711f --- /dev/null +++ b/user_guide_src/source/libraries/locks/002.php @@ -0,0 +1,5 @@ +create('reports.daily-export', 300) + ->run(static fn () => build_daily_report()); diff --git a/user_guide_src/source/libraries/locks/003.php b/user_guide_src/source/libraries/locks/003.php new file mode 100644 index 000000000000..e1e6b49a48bf --- /dev/null +++ b/user_guide_src/source/libraries/locks/003.php @@ -0,0 +1,11 @@ +create('imports.customer-feed', 300); + +if ($lock->block(10)) { + try { + import_customer_feed(); + } finally { + $lock->release(); + } +} diff --git a/user_guide_src/source/libraries/locks/004.php b/user_guide_src/source/libraries/locks/004.php new file mode 100644 index 000000000000..226a8f26e267 --- /dev/null +++ b/user_guide_src/source/libraries/locks/004.php @@ -0,0 +1,11 @@ +create('exports.monthly', 300); + +if ($lock->acquire()) { + queue_export_job($lock->owner()); +} + +// Later, in another process: +$restored = service('locks')->restore('exports.monthly', $owner); +$restored->release(); diff --git a/user_guide_src/source/libraries/security.rst b/user_guide_src/source/libraries/security.rst index 8fc49d646b1a..8c9a481ac67a 100644 --- a/user_guide_src/source/libraries/security.rst +++ b/user_guide_src/source/libraries/security.rst @@ -89,6 +89,48 @@ You can set to use the Session based CSRF protection by editing the following co .. literalinclude:: security/002.php +.. _csrf-fetch-metadata: + +Fetch Metadata CSRF Protection +------------------------------ + +.. versionadded:: 4.8.0 + +CodeIgniter can use Fetch Metadata request headers as a first-line CSRF check for unsafe browser requests. +Fetch Metadata is a browser-supplied signal that helps the framework allow same-origin requests and reject +cross-site requests before checking the CSRF token. + +When CSRF protection is enabled, new applications use Fetch Metadata first by default. You can configure +this behavior in **app/Config/Security.php**: + +.. literalinclude:: security/011.php + +When it is enabled, requests using unsafe HTTP methods with ``Sec-Fetch-Site: same-origin`` are allowed +without a token. +Requests with ``Sec-Fetch-Site: cross-site`` are rejected. Requests with a missing ``Sec-Fetch-Site`` header, +``Sec-Fetch-Site: none``, or an unknown value fall back to token verification. This keeps protection working +for browsers or clients that do not send Fetch Metadata headers. Requests with ``Sec-Fetch-Site: same-site`` +also fall back to token verification by default. + +Upgraded applications without this config value continue to use token verification. You may also disable +Fetch Metadata protection by setting ``$csrfFetchMetadata`` to ``false``. + +When an unsafe request passes with Fetch Metadata, the CSRF token is not regenerated. Token regeneration only +runs when token verification is used. + +.. warning:: Fetch Metadata protects browser-based requests. Browsers only send these headers for + potentially trustworthy URLs, and non-browser clients can send their own headers. It is not API + authentication. + +.. warning:: Same-site is not the same as same-origin, because sibling subdomains are considered same-site. + Same-site requests fall back to token verification by default. If sibling subdomains are not trusted, + you may reject same-site requests before token verification in **app/Config/Security.php**: + + .. literalinclude:: security/012.php + +If your application receives legitimate cross-origin POST requests, such as webhooks, callbacks, or +OAuth/SAML responses, use :ref:`CSRF route exclusions ` and verify those routes another way. + Token Randomization ------------------- @@ -162,6 +204,8 @@ and enabling the `csrf` filter globally: .. literalinclude:: security/006.php +.. _csrf-exclude-uris: + Select URIs can be whitelisted from CSRF protection (for example API endpoints expecting externally POSTed content). You can add these URIs by adding them as exceptions in the filter: diff --git a/user_guide_src/source/libraries/security/011.php b/user_guide_src/source/libraries/security/011.php new file mode 100644 index 000000000000..7b76ed96d72a --- /dev/null +++ b/user_guide_src/source/libraries/security/011.php @@ -0,0 +1,12 @@ +between('2024-01-01 12:00:00', '2024-01-01 13:00:00'); // true +$time->between('2024-01-01 13:00:00', '2024-01-01 12:00:00'); // true +$time->between('2024-01-01 13:00:00', '2024-01-01 14:00:00', true, 'Europe/Warsaw'); // true +$time->between('2024-01-01 12:30:00', '2024-01-01 13:00:00', false); // false diff --git a/user_guide_src/source/libraries/time/046.php b/user_guide_src/source/libraries/time/046.php new file mode 100644 index 000000000000..8da12985b78a --- /dev/null +++ b/user_guide_src/source/libraries/time/046.php @@ -0,0 +1,11 @@ +min($time2); // 2024-01-01 12:00:00 UTC +$time2->min('2024-01-01 12:30:00'); // 2024-01-01 12:30:00 UTC +$time2->min('2024-01-01 13:00:00', 'Europe/Warsaw'); // 2024-01-01 12:00:00 UTC +$time1->min(); // earlier of $time1 and now diff --git a/user_guide_src/source/libraries/time/047.php b/user_guide_src/source/libraries/time/047.php new file mode 100644 index 000000000000..63f415b422aa --- /dev/null +++ b/user_guide_src/source/libraries/time/047.php @@ -0,0 +1,11 @@ +max($time2); // 2024-01-01 13:00:00 UTC +$time1->max('2024-01-01 12:30:00'); // 2024-01-01 12:30:00 UTC +$time1->max('2024-01-01 13:00:00', 'Europe/Warsaw'); // 2024-01-01 12:00:00 UTC +$time2->max(); // later of $time2 and now diff --git a/user_guide_src/source/libraries/uri.rst b/user_guide_src/source/libraries/uri.rst index d0e05d247971..a8ff61694cad 100644 --- a/user_guide_src/source/libraries/uri.rst +++ b/user_guide_src/source/libraries/uri.rst @@ -188,6 +188,46 @@ parameter is the name of the variable, and the second parameter is the value: .. literalinclude:: uri/019.php +Changing Query Values Without Mutation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: 4.8.0 + +The immutable query methods provide alternatives to the older mutable methods: + ++-----------------------+--------------------------------------------+ +| Mutable method | Immutable alternative | ++=======================+============================================+ +| ``setQuery()`` | ``withQuery()`` | ++-----------------------+--------------------------------------------+ +| ``setQueryArray()`` | ``withQueryArray()`` | ++-----------------------+--------------------------------------------+ +| ``addQuery()`` | ``withQueryVar()`` / ``withQueryVars()`` | ++-----------------------+--------------------------------------------+ +| ``stripQuery()`` | ``withoutQueryVars()`` | ++-----------------------+--------------------------------------------+ +| ``keepQuery()`` | ``withOnlyQueryVars()`` | ++-----------------------+--------------------------------------------+ + +The ``with*()`` methods return a cloned URI instance and do not modify the original URI. +The older mutable methods change the current URI instance. + +You can return a new URI instance with its query variables replaced by using the +``withQuery()`` and ``withQueryArray()`` methods: + +.. literalinclude:: uri/029.php + +You can return a new URI instance with one or more query variables added or replaced by using the +``withQueryVar()`` and ``withQueryVars()`` methods. Existing query variables are preserved unless they +are replaced: + +.. literalinclude:: uri/028.php + +You can return a new URI instance with query variables removed or filtered by using the +``withoutQueryVars()`` and ``withOnlyQueryVars()`` methods: + +.. literalinclude:: uri/030.php + Filtering Query Values ^^^^^^^^^^^^^^^^^^^^^^ @@ -238,8 +278,7 @@ You can also set a different default value for a particular segment by using the .. literalinclude:: uri/024.php .. note:: You can get the last +1 segment. When you try to get the last +2 or - more segment, an exception will be thrown by default. You could prevent - throwing exceptions with the ``setSilent()`` method. + more segment, an exception will be thrown by default. You can get a count of the total segments: @@ -256,4 +295,8 @@ Disable Throwing Exceptions By default, some methods of this class may throw an exception. If you want to disable it, you can set a special flag that will prevent throwing exceptions. +.. deprecated:: 4.4.0 + This method is deprecated and will be removed in a future version. + It is recommended to handle exceptions properly instead of disabling them. + .. literalinclude:: uri/027.php diff --git a/user_guide_src/source/libraries/uri/008.php b/user_guide_src/source/libraries/uri/008.php index 66746092b49f..6a79d9529e0e 100644 --- a/user_guide_src/source/libraries/uri/008.php +++ b/user_guide_src/source/libraries/uri/008.php @@ -1,6 +1,7 @@ getScheme(); // 'http' -$uri->setScheme('https'); + +$uri = $uri->withScheme('https'); +echo $uri->getScheme(); // 'https' diff --git a/user_guide_src/source/libraries/uri/024.php b/user_guide_src/source/libraries/uri/024.php index 61a951db93c3..6a835b814946 100644 --- a/user_guide_src/source/libraries/uri/024.php +++ b/user_guide_src/source/libraries/uri/024.php @@ -8,7 +8,3 @@ echo $uri->getSegment(4, 'bar'); // will throw an exception echo $uri->getSegment(5, 'baz'); -// will print 'baz' -echo $uri->setSilent()->getSegment(5, 'baz'); -// will print '' (empty string) -echo $uri->setSilent()->getSegment(5); diff --git a/user_guide_src/source/libraries/uri/028.php b/user_guide_src/source/libraries/uri/028.php new file mode 100644 index 000000000000..57c37c00a7eb --- /dev/null +++ b/user_guide_src/source/libraries/uri/028.php @@ -0,0 +1,13 @@ +withQueryVar('page', 2); +// https://example.com/users?q=bob&page=2 + +$filtered = $uri->withQueryVars([ + 'q' => 'alice', + 'page' => 1, + 'role' => 'admin', +]); +// https://example.com/users?q=alice&page=1&role=admin diff --git a/user_guide_src/source/libraries/uri/029.php b/user_guide_src/source/libraries/uri/029.php new file mode 100644 index 000000000000..14af204241bd --- /dev/null +++ b/user_guide_src/source/libraries/uri/029.php @@ -0,0 +1,12 @@ +withQuery('page=2'); +// https://example.com/users?page=2 + +$filtered = $uri->withQueryArray([ + 'q' => 'alice', + 'role' => 'admin', +]); +// https://example.com/users?q=alice&role=admin diff --git a/user_guide_src/source/libraries/uri/030.php b/user_guide_src/source/libraries/uri/030.php new file mode 100644 index 000000000000..c1906a0bdcf0 --- /dev/null +++ b/user_guide_src/source/libraries/uri/030.php @@ -0,0 +1,9 @@ +withoutQueryVars('page'); +// https://example.com/users?q=bob&role=admin + +$onlyFilters = $uri->withOnlyQueryVars('q', 'role'); +// https://example.com/users?q=bob&role=admin diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 625d5c970f30..c7d76d1c0409 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -411,6 +411,8 @@ data to be validated: Working with Validation *********************** +.. _validation-running: + Running Validation ================== @@ -476,6 +478,75 @@ the validation rules. .. literalinclude:: validation/045.php :lines: 2- +.. _validation-validated-input: + +Typed Validated Input +--------------------- + +``getValidatedInput()`` returns the same validated data as a +``CodeIgniter\Input\ValidatedInput`` object. Use it after validation +succeeds when you want to read common controller values as strings, integers, +floats, booleans, arrays, dates, or enums: + +.. versionadded:: 4.8.0 + +.. literalinclude:: validation/048.php + :lines: 2- + +Validation decides whether input is acceptable. The typed input object only +helps you consume values that already passed validation. If a present value +cannot be read as the requested type, an ``InvalidArgumentException`` is thrown. +This usually means the validation rules and the expected type do not match. + +``ValidatedInput`` extends ``CodeIgniter\Input\InputData``. This keeps generic +typed input access reusable while adding validation-specific readers for dates +and enums. ``InputData`` is fallback-friendly for raw input; ``ValidatedInput`` +is strict because it represents data that has already passed validation. + +The typed input object has the following methods: + +All methods support dot-array syntax for nested validated data. + +* ``get($key, $default = null)`` returns the raw validated value. If the field + is missing, it returns the default value. +* ``has($key)`` returns whether the field exists in the validated data, even if + its value is ``null``. +* ``string($key, $default = null)`` returns ``string|null``. If the field is + missing, it returns the default value or ``null``. +* ``integer($key, $default = null)`` returns ``int|null``. If the field is + missing, it returns the default value or ``null``. +* ``float($key, $default = null)`` returns ``float|null``. If the field is + missing, it returns the default value or ``null``. +* ``boolean($key, $default = null)`` returns ``bool|null``. If the field is + missing, it returns the default value or ``null``. +* ``array($key, $default = null)`` returns ``array|null``. If the field is + missing, it returns the default value or ``null``. +* ``date($key, $format = null, $timezone = null, $default = null)`` returns + :php:class:`CodeIgniter\\I18n\\Time` or ``null``. If the field is missing, it + returns the default value or ``null``. Pass a format when the value should be + parsed with a specific date format. The default value must be ``null`` or a + :php:class:`CodeIgniter\\I18n\\Time` instance. +* ``enum($key, $enumClass, $default = null)`` returns an enum instance or + ``null``. The default value must be ``null`` or an instance of the requested + enum class. + +Fields that are present with a ``null`` value return ``null``. This lets you +distinguish a missing optional field from a field that was validated as +``null``. + +Use validation rules such as ``integer``, ``decimal``, ``valid_date``, +``in_list``, or a custom rule to ensure the value matches the type you plan to read. The +``date()`` method only parses the value; validation rules should enforce +acceptable date formats and ranges. For strict calendar validation, add a rule +such as ``valid_date[Y-m-d]``. + +The ``enum()`` method accepts PHP enum class names. Backed enums are matched by +their backing value, while unit enums are matched by case name. + +The ``boolean()`` method uses PHP's boolean validation behavior, so common form +values like ``"1"``, ``"0"``, ``"true"``, ``"false"``, ``"yes"``, ``"no"``, +``"on"``, and ``"off"`` are accepted. + .. _saving-validation-rules-to-config-file: Saving Sets of Validation Rules to the Config File @@ -762,6 +833,16 @@ right after the name of the field the error should belong to:: showError('username', 'my_single') ?> +************* +Form Requests +************* + +.. versionadded:: 4.8.0 + +For a higher-level approach that encapsulates validation rules, custom error +messages, and authorization logic in a dedicated class, see +:doc:`Form Requests `. + ********************* Creating Custom Rules ********************* @@ -802,6 +883,10 @@ fourth) parameter: .. literalinclude:: validation/035.php +.. note:: Since v4.8.0, the ``{field}``, ``{param}``, and ``{value}`` placeholders are supported in ``$error`` + messages and will be replaced with the field's human-readable label (or field name if no label is set), + the rule parameter, and the submitted value respectively. + Using a Custom Rule ------------------- @@ -854,6 +939,10 @@ Or you can use the following parameters: .. literalinclude:: validation/041.php :lines: 2- +.. note:: Since v4.8.0, the ``{field}``, ``{param}``, and ``{value}`` placeholders are supported in ``$error`` + messages and will be replaced with the field's human-readable label (or field name if no label is set), + the rule parameter, and the submitted value respectively. + .. _validation-using-callable-rule: Using Callable Rule @@ -877,6 +966,10 @@ Or you can use the following parameters: .. literalinclude:: validation/047.php :lines: 2- +.. note:: Since v4.8.0, the ``{field}``, ``{param}``, and ``{value}`` placeholders are supported in ``$error`` + messages and will be replaced with the field's human-readable label (or field name if no label is set), + the rule parameter, and the submitted value respectively. + .. _validation-available-rules: *************** diff --git a/user_guide_src/source/libraries/validation/035.php b/user_guide_src/source/libraries/validation/035.php index 7a571e232efe..0507dbbac8e0 100644 --- a/user_guide_src/source/libraries/validation/035.php +++ b/user_guide_src/source/libraries/validation/035.php @@ -6,6 +6,8 @@ public function even($value, ?string &$error = null): bool { if ((int) $value % 2 !== 0) { $error = lang('myerrors.evenError'); + // You can also use {field}, {param}, and {value} placeholders: + // $error = 'The value of {field} is not even.'; return false; } diff --git a/user_guide_src/source/libraries/validation/041.php b/user_guide_src/source/libraries/validation/041.php index bb8e4085632e..dd0f2871839f 100644 --- a/user_guide_src/source/libraries/validation/041.php +++ b/user_guide_src/source/libraries/validation/041.php @@ -8,7 +8,7 @@ static function ($value, $data, &$error, $field) { return true; } - $error = 'The value is not even.'; + $error = 'The value of {field} is not even.'; return false; }, diff --git a/user_guide_src/source/libraries/validation/047.php b/user_guide_src/source/libraries/validation/047.php index 6c2bb674f800..12b14503c35e 100644 --- a/user_guide_src/source/libraries/validation/047.php +++ b/user_guide_src/source/libraries/validation/047.php @@ -13,7 +13,7 @@ public function _ruleEven($value, $data, &$error, $field): bool return true; } - $error = 'The value is not even.'; + $error = 'The value of {field} is not even.'; return false; } diff --git a/user_guide_src/source/libraries/validation/048.php b/user_guide_src/source/libraries/validation/048.php new file mode 100644 index 000000000000..17dbbf9b30f9 --- /dev/null +++ b/user_guide_src/source/libraries/validation/048.php @@ -0,0 +1,39 @@ +setRules([ + 'title' => 'required|string', + 'page' => 'permit_empty|integer', + 'rating' => 'permit_empty|decimal', + 'active' => 'permit_empty|in_list[0,1,true,false,yes,no,on,off]', + 'tags' => 'permit_empty|is_array', + 'published_at' => 'permit_empty|valid_date[Y-m-d]', + 'status' => 'permit_empty|in_list[draft,published]', +]); + +$data = [ + 'title' => 'Hello World', + 'page' => '2', + 'rating' => '4.5', + 'active' => 'true', + 'tags' => ['php', 'codeigniter'], + 'published_at' => '2026-05-04', + 'status' => 'published', +]; + +if (! $validation->run($data)) { + // The validation failed. + return; +} + +$input = $validation->getValidatedInput(); + +$title = $input->string('title'); +$page = $input->integer('page', 1); +$rating = $input->float('rating', 0.0); +$active = $input->boolean('active', false); +$tags = $input->array('tags', []); +$publishedAt = $input->date('published_at', 'Y-m-d'); +$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT); diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index 56c17114138e..f31c2981ba87 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -261,6 +261,7 @@ Add a question mark at the beginning of type to mark property as nullable, i.e., .. note:: **int-bool** can be used since v4.3.0. .. note:: **enum** can be used since v4.7.0. +.. note:: Since v4.8.0, you can also pass parameters to **float** and **double** types to specify the number of decimal places and rounding mode, i.e., **float[2,even]**. For example, if you had a User entity with an ``is_banned`` property, you can cast it as a boolean: diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 76feb2b7c7c1..648d5b9c9b01 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -163,6 +163,23 @@ potential mass assignment vulnerabilities. .. note:: The `$primaryKey`_ field should never be an allowed field. +.. _model-throw-on-disallowed-fields: + +$throwOnDisallowedFields +------------------------ + +.. versionadded:: 4.8.0 + +When ``true``, the model throws a ``DataException`` instead of discarding fields +that are not listed in `$allowedFields`_ during ``insert()``, ``insertBatch()``, +``update()``, ``updateBatch()``, and ``save()`` calls. + +This is useful when you want to catch typos, stale form fields, or unexpected +write payloads during development or in strict application code. It does not +replace validation and does not inspect the database schema. + +You may also change this setting with the ``throwOnDisallowedFields()`` method. + $allowEmptyInserts ------------------ @@ -380,6 +397,20 @@ of type to mark the field as nullable, i.e., ``?int``, ``?datetime``. |``enum`` | Enum | string/int type | +---------------+----------------+---------------------------+ +float +----- + +Casting as ``float`` will convert the value to a float type in PHP. +This is best used with database columns that are of a float or numeric type. + +You can also pass arguments to the ``float`` type to specify the number +of decimal places to round to as well as the rounding mode (up, down, even or odd). + +.. literalinclude:: model/067.php + +.. note:: Prior to v4.8.0 the ``float`` type did not support any parameters. + It simply converted the value to a float type in PHP without rounding. + csv --- @@ -633,6 +664,41 @@ model's ``save()`` method to inspect the class, grab any public and private prop .. note:: If you find yourself working with Entities a lot, CodeIgniter provides a built-in :doc:`Entity class ` that provides several handy features that make developing Entities simpler. +.. _model-first-or-insert: + +firstOrInsert() +--------------- + +.. versionadded:: 4.8.0 + +Finds the first row matching the given ``$attributes``, or inserts a new row +combining ``$attributes`` and ``$values`` when no match is found. + +Both parameters accept an array, a ``stdClass`` object, or an +:doc:`Entity `: + +.. literalinclude:: model/065.php + +``$attributes`` is used as the WHERE condition for the lookup. If no record is +found, a new row is inserted using the merged result of ``$attributes`` and +``$values``. The ``$values`` data is only applied during insertion and is +ignored when a matching record already exists. + +.. literalinclude:: model/066.php + +The method returns the found or newly inserted row in the format defined by +`$returnType`_, or ``false`` on failure (e.g., validation error or database +error when ``DBDebug`` is ``false``). + +.. note:: A database **unique constraint** on the lookup column(s) is required + for the method to be race-safe. Without it, two concurrent requests could + both pass the initial lookup and attempt to insert, resulting in duplicate + rows. + + When a unique constraint is present, a concurrent insert is detected via + :php:class:`UniqueConstraintViolationException ` + and resolved automatically by performing a second lookup. + .. _model-saving-dates: Saving Dates @@ -857,6 +923,8 @@ So it will ignore the row in the database that has ``id=4`` when it verifies the This can also be used to create more dynamic rules at runtime, as long as you take care that any dynamic keys passed in don't conflict with your form data. +.. _model-protecting-fields: + Protecting Fields ================= @@ -867,6 +935,15 @@ or primary keys do not get changed. .. literalinclude:: model/041.php +If you prefer disallowed fields to raise an exception instead of being silently +removed, enable throwing on disallowed fields: + +.. literalinclude:: model/068.php + +When throwing on disallowed fields is enabled, operation fields such as the +primary key passed to ``update()`` or the index passed to ``updateBatch()`` may +still be used to locate rows. + Occasionally, you will find times where you need to be able to change these elements. This is often during testing, migrations, or seeds. In these cases, you can turn the protection on or off: @@ -900,14 +977,27 @@ Processing Large Amounts of Data ================================ Sometimes, you need to process large amounts of data and would run the risk of running out of memory. -To make this simpler, you may use the chunk() method to get smaller chunks of data that you can then +This is best used during cronjobs, data exports, or other large tasks. To make this simpler, you can +process the data in smaller, manageable pieces using the methods below. + +chunk() +------- + +You may use the ``chunk()`` method to get smaller chunks of data that you can then do your work on. The first parameter is the number of rows to retrieve in a single chunk. The second parameter is a Closure that will be called for each row of data. -This is best used during cronjobs, data exports, or other large tasks. - .. literalinclude:: model/049.php +chunkRows() +----------- + +.. versionadded:: 4.8.0 + +On the other hand, if you want the entire chunk to be passed to the Closure at once, you can use the ``chunkRows()`` method. + +.. literalinclude:: model/064.php + .. _model-events-callbacks: Working with Query Builder diff --git a/user_guide_src/source/models/model/064.php b/user_guide_src/source/models/model/064.php new file mode 100644 index 000000000000..9afa04e32bb0 --- /dev/null +++ b/user_guide_src/source/models/model/064.php @@ -0,0 +1,6 @@ +chunkRows(100, static function ($rows) { + // do something. + // $rows is an array of rows representing chunk of 100 items. +}); diff --git a/user_guide_src/source/models/model/065.php b/user_guide_src/source/models/model/065.php new file mode 100644 index 000000000000..a310c239daba --- /dev/null +++ b/user_guide_src/source/models/model/065.php @@ -0,0 +1,19 @@ +firstOrInsert( + ['email' => 'john@example.com'], + ['name' => 'John Doe', 'country' => 'US'], +); + +// The above will trigger: +// +// 1) First it tries to find the record: +// SELECT * FROM `users` WHERE `email` = 'john@example.com' LIMIT 1; +// +// 2) If no result is found, it inserts a new record: +// INSERT INTO `users` (`email`, `name`, `country`) +// VALUES ('john@example.com', 'John Doe', 'US'); +// +// 3) Then it returns the found or newly created entity/row, +// or false if something went wrong diff --git a/user_guide_src/source/models/model/066.php b/user_guide_src/source/models/model/066.php new file mode 100644 index 000000000000..f24b54f6ac81 --- /dev/null +++ b/user_guide_src/source/models/model/066.php @@ -0,0 +1,7 @@ +email = 'john@example.com'; + +$user = $userModel->firstOrInsert($attrs, ['name' => 'John Doe', 'country' => 'US']); diff --git a/user_guide_src/source/models/model/067.php b/user_guide_src/source/models/model/067.php new file mode 100644 index 000000000000..128fe1313956 --- /dev/null +++ b/user_guide_src/source/models/model/067.php @@ -0,0 +1,16 @@ + 'int', + 'currency' => 'string', + 'amount' => 'float[2,even]', + ]; + // ... +} diff --git a/user_guide_src/source/models/model/068.php b/user_guide_src/source/models/model/068.php new file mode 100644 index 000000000000..b74b10487c02 --- /dev/null +++ b/user_guide_src/source/models/model/068.php @@ -0,0 +1,4 @@ +throwOnDisallowedFields() + ->insert($data); diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst index 38ce1de1fc14..79f0f1c6ea50 100644 --- a/user_guide_src/source/outgoing/api_transformers.rst +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -129,6 +129,50 @@ fields are allowed by overriding the ``getAllowedFields()`` method: Now, even if a client requests ``/users/1?fields=email``, an ``ApiException`` will be thrown because ``email`` is not in the allowed fields list. +Sparse Fieldsets (Per-Type Filtering) +===================================== + +.. versionadded:: 4.8.0 + +The flat ``fields`` parameter filters the root resource. When a response also embeds +:ref:`related resources `, you can filter each related type +independently using the bracketed *sparse fieldset* form ``fields[]=...``. + +To opt a transformer into this, declare its resource type via the ``$resourceType`` property: + +.. literalinclude:: api_transformers/024.php + +A matching ``fields[]`` fieldset applies whether the transformer is used as the root resource or +as an included resource. The flat ``fields=...`` form still applies to the root transformer only. + +Each transformer reads the fieldset that matches its own ``$resourceType``. Given the ``UserTransformer`` +from :ref:`Including Related Resources ` (which includes posts), a request: + +.. code-block:: text + + /users/1?include=posts&fields[posts]=id,slug + +scopes the fields **only** for the embedded posts, leaving the user fields untouched: + +.. code-block:: json + + { + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "posts": [ + { + "id": 1, + "slug": "first-post" + } + ] + } + +When filtering multiple resource types in one request, use the bracketed form for each type, such as +``fields[users]=id,name&fields[posts]=id,slug``. + +.. _api_transformers_includes: + *************************** Including Related Resources *************************** @@ -174,10 +218,11 @@ The response would include: ] } -.. note:: The ``fields`` and ``include`` query parameters describe the **root** resource only. +.. note:: The flat ``fields=...`` and ``include`` query parameters describe the **root** resource only. Transformers instantiated inside an ``include*()`` method (such as ``PostTransformer`` above) are treated as nested resources: when created **without an explicit request** they do **not** - inherit the root request's ``fields``/``include`` state. This prevents the parent's query + inherit the root request's flat ``fields``/``include`` state. Use ``fields[]=...`` with + a matching ``$resourceType`` to filter a nested resource. This prevents the parent's query parameters from leaking into related resources, which could otherwise cause incorrect field filtering or unexpected/recursive includes. If you deliberately pass a request to a nested transformer, that request's scope is honored. @@ -264,6 +309,17 @@ Class Reference .. php:class:: BaseTransformer + .. php:attr:: $resourceType + + :type: string|null + + .. versionadded:: 4.8.0 + + The resource type this transformer represents. Used to resolve per-type sparse fieldsets from + the request (e.g. a type of ``'posts'`` reads ``?fields[posts]=...``). Defaults to ``null``, + in which case only the flat ``?fields=...`` parameter applies, and only to the root transformer. + See `Sparse Fieldsets (Per-Type Filtering)`_. + .. php:method:: __construct(?IncomingRequest $request = null) :param IncomingRequest|null $request: Optional request instance. If not provided, the global request will be used. diff --git a/user_guide_src/source/outgoing/api_transformers/024.php b/user_guide_src/source/outgoing/api_transformers/024.php new file mode 100644 index 000000000000..bd04e9f85e90 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/024.php @@ -0,0 +1,22 @@ + $resource['id'], + 'title' => $resource['title'], + 'slug' => $resource['slug'], + 'body' => $resource['body'], + 'user_id' => $resource['user_id'], + ]; + } +} diff --git a/user_guide_src/source/outgoing/csp.rst b/user_guide_src/source/outgoing/csp.rst index 0c62f3cee56d..f6abc8793aa6 100644 --- a/user_guide_src/source/outgoing/csp.rst +++ b/user_guide_src/source/outgoing/csp.rst @@ -171,3 +171,18 @@ In this case, you can use the functions, :php:func:`csp_script_nonce()` and :php + +.. _csp-control-nonce-generation: + +Control Nonce Generation +======================== + +.. versionadded:: 4.8.0 + +By default, both the script and style nonces are generated automatically. If you want to only generate one of them, +you can set ``$enableStyleNonce`` or ``$enableScriptNonce`` to false in **app/Config/ContentSecurityPolicy.php**: + +.. literalinclude:: csp/016.php + +By setting one of these to false, the corresponding nonce will not be generated, and the placeholder will be replaced with an empty string. +This gives you the flexibility to use nonces for only one type of content if you choose, without affecting the other. diff --git a/user_guide_src/source/outgoing/csp/016.php b/user_guide_src/source/outgoing/csp/016.php new file mode 100644 index 000000000000..b4063e2f4390 --- /dev/null +++ b/user_guide_src/source/outgoing/csp/016.php @@ -0,0 +1,14 @@ +`_ +over HTTP. This is useful for long-lived connections where the server pushes +events to the client. + +.. literalinclude:: response/036.php + +The callback receives the ``SSEResponse`` instance. Use ``event()``, +``comment()``, or ``retry()`` to send SSE fields. If you pass an array to +``event()``, it will be JSON-encoded. If encoding fails or the client +disconnects, ``event()`` returns ``false``. + +The response is streamed: output buffering is disabled, and the session is closed +to avoid blocking other requests. Headers must be set **before** returning the +response because anything set inside the callback will be too late. + +After filters still run. Any headers or cookies they set will be sent, but they +must not rely on the response body. View rendering, and decorators are not applied +because the response body is not built - stream your output in the callback. + +Custom Headers and Keep-Alive +----------------------------- + +If you need custom headers, set them before returning the response. You can also +use comments for keep-alive and configure the client retry interval: + +.. literalinclude:: response/037.php + +Development Server Limitations +------------------------------ + +When testing SSE locally, keep in mind that PHP's built-in development server +(used by ``php spark serve``) is not designed to handle long-lived streaming +connections effectively. + +Because an SSE connection remains open for an extended period, the built-in +server may spend most of its capacity serving the stream. As a result, other +requests may appear slow, blocked, or delayed while the SSE connection is active. + +Possible symptoms include: + +- normal HTTP requests appearing to hang or time out +- API requests responding slowly +- frontend requests remaining in a pending state + +For more realistic testing, prefer a server stack that handles concurrent +requests better, such as Apache, nginx with PHP-FPM, or FrankenPHP. + +This behavior is a limitation of the development server environment, not of +``SSEResponse`` itself. + +Production Considerations +------------------------- + +Some server stacks and CDNs buffer or compress responses (e.g., Apache with +``mod_deflate``), which can break real-time SSE delivery. +``SSEResponse`` disables PHP output buffering, turns off zlib output +compression, and sets ``Content-Encoding: identity`` and ``X-Accel-Buffering: no``. +However, intermediaries may still buffer or compress, so configure your web server +or CDN to disable buffering/compression for SSE endpoints. + +Example: Product-Oriented Use Case +---------------------------------- + +The following example simulates a small notification stream to illustrate a more +product-focused use case: + +.. literalinclude:: response/038.php + HTTP Caching ============ diff --git a/user_guide_src/source/outgoing/response/036.php b/user_guide_src/source/outgoing/response/036.php new file mode 100644 index 000000000000..9308fef80ce2 --- /dev/null +++ b/user_guide_src/source/outgoing/response/036.php @@ -0,0 +1,15 @@ +event(['text' => $text])) { + break; + } + + sleep(1); + } + + $sse->event('[DONE]'); +}); diff --git a/user_guide_src/source/outgoing/response/037.php b/user_guide_src/source/outgoing/response/037.php new file mode 100644 index 000000000000..96bc40537ca3 --- /dev/null +++ b/user_guide_src/source/outgoing/response/037.php @@ -0,0 +1,21 @@ +comment('keep-alive'); + + foreach (['one', 'two', 'three', 'four'] as $text) { + if (! $sse->event(['text' => $text])) { + break; + } + } + + sleep(1); + + $sse->retry(5000); +}); + +$sse->setHeader('X-Stream-Name', 'demo'); + +return $sse; diff --git a/user_guide_src/source/outgoing/response/038.php b/user_guide_src/source/outgoing/response/038.php new file mode 100644 index 000000000000..25f0370d28cf --- /dev/null +++ b/user_guide_src/source/outgoing/response/038.php @@ -0,0 +1,44 @@ +get('user_id'); + +return new SSEResponse(static function (SSEResponse $sse) use ($user_id) { + // Stream live notifications for the current user + $notificationModel = model(NotificationModel::class); + + $lastId = 0; + + // In a real app, you would typically keep the connection open indefinitely + for ($i = 0; $i < 6; $i++) { + $order = $lastId === 0 ? 'desc' : 'asc'; + + // On the first pass, pick the newest notification + // After that, stream any newer ones in order + $notification = $notificationModel->where('user_id', $user_id) + ->where('id >', $lastId) + ->orderBy('id', $order) + ->first(); + + if ($notification !== null) { + $lastId = (int) $notification['id']; + + if (! $sse->event($notification, 'notification', (string) $lastId)) { + break; + } + } else { + // No new notifications yet: send a keep-alive comment + if (! $sse->comment('keep-alive')) { + break; + } + } + + // Poll every 10 seconds + sleep(10); + } + + // Ask the browser to retry in 60 seconds if the connection closes + $sse->retry(60000); +}); diff --git a/user_guide_src/source/testing/feature.rst b/user_guide_src/source/testing/feature.rst index 67337fa0464c..bccefdf3b0df 100644 --- a/user_guide_src/source/testing/feature.rst +++ b/user_guide_src/source/testing/feature.rst @@ -29,7 +29,7 @@ Requesting a Page Essentially, feature tests simply allows you to call an endpoint on your application and get the results back. To do this, you use the ``call()`` method. -1. The first parameter is the HTTP method to use (most frequently either ``GET`` or ``POST``). +1. The first parameter is the uppercase HTTP method to use (most frequently either ``GET`` or ``POST``). 2. The second parameter is the URI path on your site to test. 3. The third parameter ``$params`` accepts an array that is used to populate the superglobal variables for the HTTP verb you are using. So, a method of **GET** @@ -60,7 +60,7 @@ override any existing routes in the system: .. literalinclude:: feature/004.php :lines: 2- -Each of the "routes" is a 3 element array containing the HTTP verb (or "add" for all), +Each of the "routes" is a 3 element array containing the uppercase HTTP verb (or "add" for all), the URI to match, and the routing destination. .. _feature-setting-session-values: diff --git a/user_guide_src/source/testing/feature/002.php b/user_guide_src/source/testing/feature/002.php index a47406b24aeb..37022ddff517 100644 --- a/user_guide_src/source/testing/feature/002.php +++ b/user_guide_src/source/testing/feature/002.php @@ -4,7 +4,7 @@ $result = $this->call('GET', '/'); // Submit a form -$result = $this->call('post', 'contact', [ +$result = $this->call('POST', 'contact', [ 'name' => 'Fred Flintstone', 'email' => 'flintyfred@example.com', ]); diff --git a/user_guide_src/source/testing/overview.rst b/user_guide_src/source/testing/overview.rst index 4d49c5569355..b00cbb8539d7 100644 --- a/user_guide_src/source/testing/overview.rst +++ b/user_guide_src/source/testing/overview.rst @@ -202,6 +202,13 @@ between expected and actual time, formatted as strings, is within the prescribed The above test will allow the actual time to be either 660 or 661 seconds. +assertSameSql($expected, $actual, $message = '') +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Asserts that two SQL strings are the same, ignoring newlines in the actual SQL: + +.. literalinclude:: overview/023.php + Accessing Protected/Private Properties -------------------------------------- diff --git a/user_guide_src/source/testing/overview/023.php b/user_guide_src/source/testing/overview/023.php new file mode 100644 index 000000000000..32c126ca780b --- /dev/null +++ b/user_guide_src/source/testing/overview/023.php @@ -0,0 +1,6 @@ +where('id', 1)->getCompiledSelect(); + +$this->assertSameSql($expected, $actual); diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index bbd0685fcf76..8129c4c24dce 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,12 +1,7 @@ -# total 73 errors +# total 67 errors parameters: ignoreErrors: - - - message: '#^Parameter \#2 \$params of method CodeIgniter\\CLI\\BaseCommand\:\:call\(\) expects array\, array\ given\.$#' - count: 2 - path: ../../system/Commands/Database/MigrateRefresh.php - - message: '#^Parameter \#3 \.\.\.\$arrays of function array_map expects array, int\|string given\.$#' count: 1 @@ -27,26 +22,6 @@ parameters: count: 1 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\\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\\DownloadResponse\|null\) 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\)\: non\-falsy\-string 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\)\: void given\.$#' - count: 1 - path: ../../tests/system/CodeIgniterTest.php - - message: '#^Parameter \#1 \$expected of method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) expects class\-string\, string given\.$#' count: 1 diff --git a/utils/phpstan-baseline/arguments.count.neon b/utils/phpstan-baseline/arguments.count.neon index 1df396472752..8c7a8aecacc3 100644 --- a/utils/phpstan-baseline/arguments.count.neon +++ b/utils/phpstan-baseline/arguments.count.neon @@ -1,4 +1,4 @@ -# total 2 errors +# total 3 errors parameters: ignoreErrors: @@ -6,3 +6,8 @@ parameters: message: '#^Function is_cli invoked with 1 parameter, 0 required\.$#' count: 2 path: ../../tests/system/Debug/ToolbarTest.php + + - + message: '#^Class CodeIgniter\\HTTP\\Response constructor invoked with 1 parameter, 0 required\.$#' + count: 1 + path: ../../tests/system/HTTP/ResponseTest.php diff --git a/utils/phpstan-baseline/codeigniter.superglobalsOffsetAssign.neon b/utils/phpstan-baseline/codeigniter.superglobalsOffsetAssign.neon new file mode 100644 index 000000000000..3f7d0a28910c --- /dev/null +++ b/utils/phpstan-baseline/codeigniter.superglobalsOffsetAssign.neon @@ -0,0 +1,13 @@ +# total 2 errors + +parameters: + ignoreErrors: + - + message: '#^Direct assignment of ''cookie\-secret'' to \$_COOKIE\[''debug_cookie''\] is not allowed\.$#' + count: 1 + path: ../../tests/system/Debug/ExceptionHandlerTest.php + + - + message: '#^Direct assignment of ''post\-secret'' to \$_POST\[''debug_post''\] is not allowed\.$#' + count: 1 + path: ../../tests/system/Debug/ExceptionHandlerTest.php diff --git a/utils/phpstan-baseline/codeigniter.superglobalsOffsetUnset.neon b/utils/phpstan-baseline/codeigniter.superglobalsOffsetUnset.neon new file mode 100644 index 000000000000..76b66fa9cc74 --- /dev/null +++ b/utils/phpstan-baseline/codeigniter.superglobalsOffsetUnset.neon @@ -0,0 +1,13 @@ +# total 2 errors + +parameters: + ignoreErrors: + - + message: '#^Direct unset of \$_COOKIE\[''debug_cookie''\] is not allowed\.$#' + count: 1 + path: ../../tests/system/Debug/ExceptionHandlerTest.php + + - + message: '#^Direct unset of \$_POST\[''debug_post''\] is not allowed\.$#' + count: 1 + path: ../../tests/system/Debug/ExceptionHandlerTest.php diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index a302b0433f39..3bbd27098973 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,22 +1,7 @@ -# total 213 errors +# total 207 errors parameters: ignoreErrors: - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 1 - path: ../../system/Commands/Database/CreateDatabase.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 1 - path: ../../system/Commands/Database/MigrateStatus.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 1 - path: ../../system/Commands/Database/Seed.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' count: 2 @@ -34,7 +19,7 @@ parameters: - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 27 + count: 24 path: ../../system/Database/BaseBuilder.php - diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 8c8e2bbe900e..60950e3e2919 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,10 +1,12 @@ -# total 1839 errors +# total 1799 errors includes: - argument.type.neon - arguments.count.neon - assign.propertyType.neon - codeigniter.modelArgumentType.neon + - codeigniter.superglobalsOffsetAssign.neon + - codeigniter.superglobalsOffsetUnset.neon - deadCode.unreachable.neon - empty.notAllowed.neon - function.resultUnused.neon @@ -12,6 +14,7 @@ includes: - method.childParameterType.neon - method.childReturnType.neon - method.notFound.neon + - missingType.callable.neon - missingType.iterableValue.neon - missingType.parameter.neon - missingType.property.neon diff --git a/utils/phpstan-baseline/method.alreadyNarrowedType.neon b/utils/phpstan-baseline/method.alreadyNarrowedType.neon index f3c373da1f4a..974f33a134e7 100644 --- a/utils/phpstan-baseline/method.alreadyNarrowedType.neon +++ b/utils/phpstan-baseline/method.alreadyNarrowedType.neon @@ -1,4 +1,4 @@ -# total 22 errors +# total 21 errors parameters: ignoreErrors: @@ -47,11 +47,6 @@ parameters: count: 1 path: ../../tests/system/HTTP/RequestTest.php - - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' - count: 1 - path: ../../tests/system/HTTP/URITest.php - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#' count: 1 diff --git a/utils/phpstan-baseline/method.childParameterType.neon b/utils/phpstan-baseline/method.childParameterType.neon index 21752412ee34..f4316313dd85 100644 --- a/utils/phpstan-baseline/method.childParameterType.neon +++ b/utils/phpstan-baseline/method.childParameterType.neon @@ -1,12 +1,7 @@ -# total 11 errors +# total 10 errors parameters: ignoreErrors: - - - message: '#^Parameter \#1 \$params \(array\\) of method CodeIgniter\\Commands\\Database\\MigrateStatus\:\:run\(\) should be contravariant with parameter \$params \(array\\) of method CodeIgniter\\CLI\\BaseCommand\:\:run\(\)$#' - count: 1 - path: ../../system/Commands/Database/MigrateStatus.php - - message: '#^Parameter \#1 \$offset \(string\) of method CodeIgniter\\Cookie\\Cookie\:\:offsetSet\(\) should be contravariant with parameter \$offset \(string\|null\) of method ArrayAccess\\:\:offsetSet\(\)$#' count: 1 diff --git a/utils/phpstan-baseline/missingType.callable.neon b/utils/phpstan-baseline/missingType.callable.neon new file mode 100644 index 000000000000..2022dc536407 --- /dev/null +++ b/utils/phpstan-baseline/missingType.callable.neon @@ -0,0 +1,98 @@ +# total 19 errors + +parameters: + ignoreErrors: + - + message: '#^Property CodeIgniter\\CodeIgniter\:\:\$controller type has no signature specified for Closure\.$#' + count: 1 + path: ../../system/CodeIgniter.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:add\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:cli\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:create\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:delete\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:getControllerName\(\) has parameter \$handler with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:get\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:head\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:match\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:options\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:patch\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:post\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollection\:\:put\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollection.php + + - + message: '#^Method CodeIgniter\\Router\\RouteCollectionInterface\:\:add\(\) has parameter \$to with no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouteCollectionInterface.php + + - + message: '#^Method CodeIgniter\\Router\\Router\:\:controllerName\(\) return type has no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/Router.php + + - + message: '#^Method CodeIgniter\\Router\\Router\:\:handle\(\) return type has no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/Router.php + + - + message: '#^Property CodeIgniter\\Router\\Router\:\:\$controller type has no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/Router.php + + - + message: '#^Method CodeIgniter\\Router\\RouterInterface\:\:controllerName\(\) return type has no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouterInterface.php + + - + message: '#^Method CodeIgniter\\Router\\RouterInterface\:\:handle\(\) return type has no signature specified for Closure\.$#' + count: 1 + path: ../../system/Router/RouterInterface.php diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 80d45074b96e..b8782060381e 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1153 errors +# total 1106 errors parameters: ignoreErrors: @@ -57,56 +57,11 @@ parameters: count: 1 path: ../../system/CLI/CLI.php - - - message: '#^Method CodeIgniter\\CLI\\Console\:\:parseParamsForHelpOption\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/Console.php - - message: '#^Method CodeIgniter\\CodeIgniter\:\:getPerformanceStats\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/CodeIgniter.php - - - message: '#^Method CodeIgniter\\Commands\\ListCommands\:\:listFull\(\) has parameter \$commands with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/ListCommands.php - - - - message: '#^Method CodeIgniter\\Commands\\ListCommands\:\:listSimple\(\) has parameter \$commands with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/ListCommands.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:arrayToTableRows\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:arrayToTableRows\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:buildMultiArray\(\) has parameter \$fromKeys with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:buildMultiArray\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:findTranslationsInFile\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:templateFile\(\) has parameter \$language with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/Translation/LocalizationFinder.php - - message: '#^Method CodeIgniter\\Commands\\Utilities\\Namespaces\:\:outputAllNamespaces\(\) has parameter \$params with no value type specified in iterable type array\.$#' count: 1 @@ -217,11 +172,6 @@ parameters: count: 1 path: ../../system/Config/BaseService.php - - - message: '#^Property CodeIgniter\\Config\\BaseService\:\:\$services type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Config/BaseService.php - - message: '#^Method CodeIgniter\\Config\\DotEnv\:\:normaliseVariable\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -607,11 +557,6 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php - - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:setUpdateBatch\(\) has parameter \$key 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\.$#' count: 1 @@ -1082,11 +1027,6 @@ parameters: count: 1 path: ../../system/Database/Forge.php - - - 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 - - message: '#^Method CodeIgniter\\Database\\Forge\:\:_processColumn\(\) has parameter \$processedField with no value type specified in iterable type array\.$#' count: 1 @@ -1742,21 +1682,6 @@ parameters: count: 1 path: ../../system/Debug/BaseExceptionHandler.php - - - message: '#^Method CodeIgniter\\Debug\\BaseExceptionHandler\:\:maskData\(\) has parameter \$args with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/BaseExceptionHandler.php - - - - message: '#^Method CodeIgniter\\Debug\\BaseExceptionHandler\:\:maskData\(\) has parameter \$keysToMask with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/BaseExceptionHandler.php - - - - message: '#^Method CodeIgniter\\Debug\\BaseExceptionHandler\:\:maskData\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/BaseExceptionHandler.php - - message: '#^Method CodeIgniter\\Debug\\BaseExceptionHandler\:\:maskSensitiveData\(\) has parameter \$keysToMask with no value type specified in iterable type array\.$#' count: 1 @@ -1772,46 +1697,11 @@ parameters: count: 1 path: ../../system/Debug/BaseExceptionHandler.php - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:collectVars\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:determineCodes\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Debug/Exceptions.php - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:maskData\(\) has parameter \$args with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:maskData\(\) has parameter \$keysToMask with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:maskData\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:maskSensitiveData\(\) has parameter \$keysToMask with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:maskSensitiveData\(\) has parameter \$trace with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - - - message: '#^Method CodeIgniter\\Debug\\Exceptions\:\:maskSensitiveData\(\) return type has 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 @@ -2202,11 +2092,6 @@ parameters: count: 1 path: ../../system/Filters/PerformanceMetrics.php - - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getArgs\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getCookie\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 @@ -2247,11 +2132,6 @@ parameters: count: 1 path: ../../system/HTTP/CLIRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getOptions\(\) 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\.$#' count: 1 @@ -2282,11 +2162,6 @@ parameters: count: 1 path: ../../system/HTTP/CLIRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:getSegments\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CLIRequest\:\:returnNullOrEmptyArray\(\) has parameter \$index with no value type specified in iterable type array\.$#' count: 1 @@ -2297,21 +2172,6 @@ parameters: count: 1 path: ../../system/HTTP/CLIRequest.php - - - message: '#^Property CodeIgniter\\HTTP\\CLIRequest\:\:\$args type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/CLIRequest.php - - - - message: '#^Property CodeIgniter\\HTTP\\CLIRequest\:\:\$options type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/CLIRequest.php - - - - message: '#^Property CodeIgniter\\HTTP\\CLIRequest\:\:\$segments type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/CLIRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CURLRequest\:\:applyBody\(\) has parameter \$curlOptions with no value type specified in iterable type array\.$#' count: 1 @@ -2587,11 +2447,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\Message\:\:getHeader\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/Message.php - - message: '#^Method CodeIgniter\\HTTP\\Message\:\:setHeader\(\) has parameter \$value with no value type specified in iterable type array\.$#' count: 1 @@ -2702,16 +2557,6 @@ parameters: count: 1 path: ../../system/HTTP/Request.php - - - message: '#^Method CodeIgniter\\HTTP\\Request\:\:getEnv\(\) has parameter \$flags with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/Request.php - - - - message: '#^Method CodeIgniter\\HTTP\\Request\:\:getEnv\(\) has parameter \$index with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/Request.php - - message: '#^Method CodeIgniter\\HTTP\\Request\:\:getServer\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 @@ -2767,11 +2612,6 @@ parameters: count: 1 path: ../../system/HTTP/Response.php - - - message: '#^Property CodeIgniter\\HTTP\\Response\:\:\$statusCodes type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/Response.php - - message: '#^Method CodeIgniter\\HTTP\\ResponseInterface\:\:setCache\(\) has parameter \$options with no value type specified in iterable type array\.$#' count: 1 @@ -2797,11 +2637,6 @@ parameters: count: 1 path: ../../system/HTTP/SiteURI.php - - - message: '#^Method CodeIgniter\\HTTP\\SiteURI\:\:convertToSegments\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/SiteURI.php - - message: '#^Method CodeIgniter\\HTTP\\SiteURI\:\:parseRelativePath\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -2817,56 +2652,21 @@ parameters: count: 1 path: ../../system/HTTP/SiteURI.php - - - message: '#^Property CodeIgniter\\HTTP\\SiteURI\:\:\$baseSegments type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/SiteURI.php - - message: '#^Method CodeIgniter\\HTTP\\URI\:\:setQueryArray\(\) has parameter \$query with no value type specified in iterable type array\.$#' count: 1 path: ../../system/HTTP/URI.php - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:arrayAttachIndexedValue\(\) has parameter \$indexes with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:arrayAttachIndexedValue\(\) has parameter \$result with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Helpers/Array/ArrayHelper.php - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:arrayAttachIndexedValue\(\) has parameter \$row with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:arrayAttachIndexedValue\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Helpers/Array/ArrayHelper.php - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:arraySearchDot\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:arraySearchDot\(\) has parameter \$indexes with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:dotKeyExists\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:dotSearch\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:groupBy\(\) has parameter \$array with no value type specified in iterable type array\.$#' count: 1 @@ -2942,11 +2742,6 @@ parameters: count: 1 path: ../../system/Helpers/array_helper.php - - - message: '#^Function dot_array_search\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/array_helper.php - - message: '#^Function directory_map\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -4252,26 +4047,11 @@ parameters: count: 1 path: ../../tests/system/AutoReview/ComposerJsonTest.php - - - message: '#^Method CodeIgniter\\AutoReview\\FrameworkCodeTest\:\:getTestClasses\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/AutoReview/FrameworkCodeTest.php - - message: '#^Method CodeIgniter\\AutoReview\\FrameworkCodeTest\:\:provideEachTestClassHasCorrectGroupAttributeName\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 path: ../../tests/system/AutoReview/FrameworkCodeTest.php - - - message: '#^Property CodeIgniter\\AutoReview\\FrameworkCodeTest\:\:\$recognizedGroupAttributeNames type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/AutoReview/FrameworkCodeTest.php - - - - message: '#^Property CodeIgniter\\AutoReview\\FrameworkCodeTest\:\:\$testClasses type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/AutoReview/FrameworkCodeTest.php - - message: '#^Method CodeIgniter\\CLI\\CLITest\:\:provideTable\(\) return type has no value type specified in iterable type iterable\.$#' count: 1 @@ -4307,21 +4087,6 @@ parameters: count: 1 path: ../../tests/system/CodeIgniterTest.php - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinderTest\:\:getActualTranslationFourKeys\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/Commands/Translation/LocalizationFinderTest.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinderTest\:\:getActualTranslationOneKeys\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/Commands/Translation/LocalizationFinderTest.php - - - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinderTest\:\:getActualTranslationThreeKeys\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../tests/system/Commands/Translation/LocalizationFinderTest.php - - message: '#^Method CodeIgniter\\Commands\\Utilities\\Routes\\AutoRouterImproved\\AutoRouteCollectorTest\:\:createAutoRouteCollector\(\) has parameter \$filterConfigFilters with no value type specified in iterable type array\.$#' count: 1 @@ -4703,9 +4468,9 @@ parameters: path: ../../tests/system/HTTP/URITest.php - - message: '#^Property CodeIgniter\\Helpers\\Array\\ArrayHelperDotKeyExistsTest\:\:\$array type has no value type specified in iterable type array\.$#' + message: '#^Property CodeIgniter\\Helpers\\Array\\ArrayHelperDotHasTest\:\:\$array type has no value type specified in iterable type array\.$#' count: 1 - path: ../../tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php + path: ../../tests/system/Helpers/Array/ArrayHelperDotHasTest.php - message: '#^Property CodeIgniter\\Helpers\\Array\\ArrayHelperRecursiveDiffTest\:\:\$compareWith type has no value type specified in iterable type array\.$#' diff --git a/utils/phpstan-baseline/property.phpDocType.neon b/utils/phpstan-baseline/property.phpDocType.neon index 0ba558aded06..999bb47ea489 100644 --- a/utils/phpstan-baseline/property.phpDocType.neon +++ b/utils/phpstan-baseline/property.phpDocType.neon @@ -1,4 +1,4 @@ -# total 44 errors +# total 42 errors parameters: ignoreErrors: @@ -37,11 +37,6 @@ parameters: count: 1 path: ../../system/Database/OCI8/Forge.php - - - message: '#^PHPDoc type false of property CodeIgniter\\Database\\OCI8\\Forge\:\:\$createTableIfStr is not the same as PHPDoc type bool\|string of overridden property CodeIgniter\\Database\\Forge\:\:\$createTableIfStr\.$#' - count: 1 - path: ../../system/Database/OCI8/Forge.php - - message: '#^PHPDoc type false of property CodeIgniter\\Database\\OCI8\\Forge\:\:\$dropDatabaseStr is not the same as PHPDoc type string\|false of overridden property CodeIgniter\\Database\\Forge\:\:\$dropDatabaseStr\.$#' count: 1 @@ -102,11 +97,6 @@ parameters: count: 1 path: ../../system/Database/SQLSRV/Forge.php - - - message: '#^PHPDoc type string of property CodeIgniter\\Database\\SQLSRV\\Forge\:\:\$createTableIfStr is not the same as PHPDoc type bool\|string of overridden property CodeIgniter\\Database\\Forge\:\:\$createTableIfStr\.$#' - count: 1 - path: ../../system/Database/SQLSRV/Forge.php - - message: '#^PHPDoc type string of property CodeIgniter\\Database\\SQLSRV\\Forge\:\:\$renameTableStr is not the same as PHPDoc type string\|false of overridden property CodeIgniter\\Database\\Forge\:\:\$renameTableStr\.$#' count: 1 diff --git a/utils/phpstan-baseline/return.type.neon b/utils/phpstan-baseline/return.type.neon index cd3da83ccce5..6bde4b7c8828 100644 --- a/utils/phpstan-baseline/return.type.neon +++ b/utils/phpstan-baseline/return.type.neon @@ -1,12 +1,7 @@ -# total 3 errors +# total 2 errors parameters: ignoreErrors: - - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:cleanClone\(\) should return \$this\(CodeIgniter\\Database\\BaseBuilder\) but returns static\(CodeIgniter\\Database\\BaseBuilder\)\.$#' - count: 1 - path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\DatetimeCast\:\:get\(\) should return CodeIgniter\\I18n\\Time but returns mixed\.$#' count: 1 diff --git a/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php b/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php index ad2f64ba07a2..46f987ecdeb8 100644 --- a/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php +++ b/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php @@ -43,7 +43,7 @@ public function getRuleCustomisers(): array ), 'ordered_imports' => static fn (SplFileInfo $file): bool => ! $normalisedStrEndsWith( $file->getPathname(), - '/tests/_support/Commands/Foobar.php', + '/tests/_support/Commands/Legacy/Foobar.php', ), ]; }