diff --git a/.gitattributes b/.gitattributes index 05fc1f38cde9..02a23de3322d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,6 +22,7 @@ utils/ export-ignore .php-cs-fixer.no-header.php export-ignore .php-cs-fixer.tests.php export-ignore .php-cs-fixer.user-guide.php export-ignore +structarmed-baseline.php export-ignore structarmed.php export-ignore phpmetrics.json export-ignore phpstan-baseline.php export-ignore @@ -32,6 +33,7 @@ psalm-baseline.xml export-ignore psalm.xml export-ignore psalm_autoload.php export-ignore rector.php export-ignore +AGENTS.md export-ignore # source user guide user_guide_src/ export-ignore diff --git a/.github/carson.yml b/.github/carson.yml new file mode 100644 index 000000000000..07c841b7a021 --- /dev/null +++ b/.github/carson.yml @@ -0,0 +1,125 @@ +# https://github.com/NexusPHP/carson/blob/1.x/SUBSCRIBERS.md + +version: 1 +subscribers: + - auto-labeler + - conflicts-notifier + - no-merge-commits + - no-response-closer + - pr-title-linter + - signed-commits + - template-enforcer + - welcome +settings: + auto-labeler: + sync_labels: true + sync_exempt: + - enhancement + rules: + - label: '4.8' # @todo change value whenever the next minor version is changed + base_branch: ['^4\.8$'] + - label: github_actions + files: ['.github/workflows/*'] + - label: documentation + files: + all: ['user_guide_src/source/**'] + - label: testing + files: + all: ['tests/**'] + - label: bug + title: ['^fix(\([^)]*\))?: '] + - label: enhancement + title: ['^perf(\([^)]*\))?: '] + - label: refactor + title: ['^refactor(\([^)]*\))?: '] + conflicts-notifier: + label: stale + message: |- + :wave: Hi, @{{user}}! + + We detected conflicts in your PR against the base branch :speak_no_evil: + You may want to sync :arrows_counterclockwise: your branch with upstream! + + Ref: [Syncing Your Branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/workflow.md#updating-your-branch) + no-merge-commits: + name: 'Carson / no-merge-commits' + treat_merge_commits_as: failure + exempt_branches: + - head: '^develop$' + base: '^master$' + - head: '^4\.\d+$' + base: '^develop$' + no-response-closer: + rules: + - label: needs template + only: issues + days_until_close: 2 + close_message: |- + Closing this issue: the description still does not follow the bug report template after {{days_until_close}} days. + + Please open a new issue using the [bug report form](https://github.com/codeigniter4/CodeIgniter4/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Bug%3A+). For feature requests or support questions, please use the [forums](https://forum.codeigniter.com/forum-30.html). + - label: waiting for info + days_until_close: 14 + unlabel_on_response: true + close_message: |- + Closing this {{type}}: there has been no response for {{days_until_close}} days after more information was requested. + + If you can provide the requested details, leave a comment and a maintainer can reopen it. + pr-title-linter: + name: 'Carson / pr-title-linter' + rules: + - pattern: '^(?:(?:\[\d+\.\d+\] )?(?:feat|fix|chore|docs|perf|refactor|style|test|config|revert)(?:\([\-.@:`a-zA-Z0-9]+\))?!?: \S.+\S|Prep for \d+\.\d+\.\d+ release|\d+\.\d+\.\d+ (?:Ready|Merge) code)$' + description: 'PR title must include the type (feat, fix, chore, docs, perf, refactor, style, test, config, revert) of the commit per Conventional Commits specification. See https://www.conventionalcommits.org/en/v1.0.0/ for the discussion.' + level: error + signed-commits: + name: 'Carson / signed-commits' + treat_unsigned_as: failure + template-enforcer: + label: needs template + exempt_roles: [admin, maintain, write, triage] + message: |- + Hi there, @{{user}}! :wave: + + It looks like this {{type}} does not follow our template: + + {{violations}} + + Please update the description to follow the template. The `{{label}}` label will be removed automatically once it does. + issues: + rules: + - pattern: '### PHP Version[\s\S]*### CodeIgniter4 Version' + description: 'Use the [bug report form](https://github.com/codeigniter4/CodeIgniter4/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Bug%3A+), which asks for your PHP and CodeIgniter4 versions. For a feature request or support question, please use the [forums](https://forum.codeigniter.com/forum-30.html).' + pull_requests: + rules: + - pattern: '\*\*Description\*\*' + description: 'Keep the **Description** heading from the [pull request template](https://github.com/codeigniter4/CodeIgniter4/blob/develop/.github/PULL_REQUEST_TEMPLATE.md) and explain what you have changed, and why.' + - pattern: '\*\*Checklist:?\*\*' + description: 'Keep the **Checklist** from the pull request template and tick the items that apply. See [what we expect in a pull request](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md).' + - pattern: 'Explain what you have changed, and why\.' + description: 'Replace the placeholder text under **Description** with your own explanation.' + mode: forbid + welcome: + first_time: + pull_request: |- + Hi there, @{{user}}! :wave: + + Thank you for sending this PR! + + We expect the following in all Pull Requests (PRs). + - PRs must be sent to the [appropriate branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#branching) + - All git commits must be [GPG-signed](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#signing) + - Must follow our [style guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#php-style) + - Be [commented](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#comments) in the PHP source file + - Be documented in the [user guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#user-guide) + - Be [unit tested](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#unit-testing) + - Pass all checks in GitHub Actions + + > [!IMPORTANT] + > We expect all code changes or bug-fixes to be accompanied by one or more tests added to our test suite to prove the code works. + + If pull requests do not comply with the above, they will likely be closed. Since we are a team of volunteers, we don't have any more time to work + on the framework than you do. Please make it as painless for your contributions to be included as possible. + + See https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md + issue: false + returning: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000000..4ad38ee3eadf --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,30 @@ +# Copilot Code Review Instructions + +Repository-wide conventions, the target-branch compatibility policy, coding +standards, and FrankenPHP Worker Mode requirements are defined in the root +`AGENTS.md`. Apply that policy in every review; this file covers review +behavior only. + +## Review priorities + +Prioritize actionable findings involving: + +- incorrect behavior or an unhandled edge case; +- backward compatibility; +- security, input validation, and context-appropriate output escaping; +- request-specific state leaking through globals, static properties, or + shared services, including under Worker Mode where one process serves + many requests; +- differences between supported database drivers (MySQLi, PostgreSQL, + SQLite3, SQLSRV, OCI8); +- missing regression tests, user-guide changes, changelog entries, or + upgrade instructions. + +## Review behavior + +- Determine the PR base branch and apply the compatibility policy from + `AGENTS.md`. If the base branch cannot be determined, assume `develop` + and state that assumption. +- Do not report formatting-only issues already enforced by PHP-CS-Fixer. +- Every finding should identify a concrete failure scenario and the + affected code. diff --git a/.github/instructions/database.instructions.md b/.github/instructions/database.instructions.md new file mode 100644 index 000000000000..b550a4a0573e --- /dev/null +++ b/.github/instructions/database.instructions.md @@ -0,0 +1,19 @@ +--- +applyTo: "system/Database/**/*.php,tests/system/Database/**/*.php" +--- + +# Database layer + +- Consider all supported drivers: MySQLi, PostgreSQL, SQLite3, SQLSRV, and + OCI8. +- Check identifier quoting, value binding, `NULL` behavior, transactions, + affected rows, return types, and platform-specific SQL. +- Do not implement driver-independent behavior in only one driver. +- When changing a base database class, inspect driver overrides and their + method signatures. +- Keep driver-independent tests separate from tests that require a live + database. +- Use the `DatabaseLive` PHPUnit group only when a real database connection is + required. +- Prefer focused tests for the affected base class and drivers. Leave the full + multi-driver matrix to GitHub Actions. diff --git a/.github/instructions/framework-core.instructions.md b/.github/instructions/framework-core.instructions.md new file mode 100644 index 000000000000..0ae53495c513 --- /dev/null +++ b/.github/instructions/framework-core.instructions.md @@ -0,0 +1,23 @@ +--- +applyTo: "system/**/*.php" +--- + +# Framework core + +- Assume public and protected symbols are extension points used by third-party + applications unless they are clearly marked internal. +- Apply the target-branch compatibility policy from the root `AGENTS.md` + before changing any extension point. +- Before changing a signature or behavior, inspect parent classes, implemented + interfaces, traits, service factories, configuration, and corresponding + tests. +- Keep file paths, class names, and namespaces aligned under `CodeIgniter\`. +- Preserve existing extension and dependency-injection points. +- Consider long-running worker environments. Do not retain request-specific + state in static properties, globals, or shared services after a request + finishes. +- Avoid introducing a framework-wide abstraction to solve a single local + problem. +- On `develop`, keep exceptions and observable side effects compatible. On a + `4.*` target, change them only as an explicit, narrow, documented breaking + enhancement with a migration path. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md new file mode 100644 index 000000000000..af2d9a96ba96 --- /dev/null +++ b/.github/instructions/tests.instructions.md @@ -0,0 +1,21 @@ +--- +applyTo: "tests/**/*.php" +--- + +# PHPUnit tests + +- Mirror the corresponding `system/` component and local test conventions + where practical. +- Add the appropriate PHPUnit `Group` attribute described in + `tests/README.md`. +- Prefer `assertSame()` and dedicated assertions over loose or generic + assertions. +- Cover the regression or behavior through a public API where possible. + Avoid testing private implementation details. +- Restore services, factories, environment variables, superglobals, handlers, + locale, time-related state, and other global state modified by a test. +- Tests must not depend on execution order or state left by another test. +- Do not replace a meaningful assertion with a weaker assertion to make a test + pass. +- After changing behavior, run the smallest relevant test file or component. + Do not run the complete test suite by default. diff --git a/.github/instructions/user-guide.instructions.md b/.github/instructions/user-guide.instructions.md new file mode 100644 index 000000000000..9b55a50ba3f1 --- /dev/null +++ b/.github/instructions/user-guide.instructions.md @@ -0,0 +1,27 @@ +--- +applyTo: "user_guide_src/**/*.rst" +--- + +# User guide + +- Preserve the existing reStructuredText structure, terminology, directives, + anchors, and surrounding writing style. +- Keep PHP examples compatible with PHP 8.2 and verify signatures and behavior + against the current framework code. +- Distinguish new behavior from existing behavior and document defaults + precisely. +- Determine the PR target branch before describing compatibility: + `develop` follows the patch-release policy, while `4.*` follows the + minor-release policy in the root `AGENTS.md`. +- Behavior changes, enhancements, deprecations, and important bug fixes may + require a changelog entry. +- Changes requiring users to modify code or configuration may require an + upgrading-guide entry. +- For an intentional compatibility break targeting `4.*`, document the old + behavior, new behavior, affected users, reason for the change, and the + smallest migration in both the minor changelog and upgrading guide. +- Keep code examples minimal but complete enough to run in a normal + CodeIgniter application. +- Leave the complete Sphinx build and documentation validation to GitHub + Actions unless the task specifically requires local documentation + validation. diff --git a/.github/instructions/worker-mode.instructions.md b/.github/instructions/worker-mode.instructions.md new file mode 100644 index 000000000000..ec6e1742ec5a --- /dev/null +++ b/.github/instructions/worker-mode.instructions.md @@ -0,0 +1,38 @@ +--- +applyTo: "system/Boot.php,system/CodeIgniter.php,system/Commands/Worker/**,system/Config/BaseService.php,system/Config/Factories.php,system/Config/Factory.php,system/Config/Services.php,system/Database/Config.php,system/Database/BaseConnection.php,system/Database/ConnectionInterface.php,system/Database/*/Connection.php,system/Events/Events.php,system/Session/**,system/Cache/**,system/HTTP/**,system/Filters/**,system/Router/**,system/Security/**,system/Debug/Toolbar.php,system/Debug/Toolbar/**,app/Config/WorkerMode.php,tests/system/Commands/Worker/**,user_guide_src/source/installation/worker_mode.rst" +--- + +# FrankenPHP Worker Mode + +Use `system/Commands/Worker/Views/frankenphp-worker.php.tpl` as the canonical +Worker Mode entry point. `worker:install` publishes this template to +`public/frankenphp-worker.php`. + +When changing bootstrap or request-lifecycle code, trace all three Worker Mode +phases: + +1. One-time process bootstrap through `Boot::bootWorker()`. +2. Per-request preparation: reconnect database and cache connections, reset + the `CodeIgniter` instance, and replace all request superglobals before + calling `$app->run()`. +3. Post-request cleanup: close the session, clean up unfinished database + transactions, reset factories and non-persistent services, clean up event + listeners and performance logs, and reset the debug toolbar. + +- Preserve the phase ordering unless the change explicitly proves a different + order is safe. +- Check every new mutable static property, singleton, shared service, event + listener, connection, handler, or global for cross-request state leakage. +- Do not preserve a service across requests merely for performance. Persistent + services must be safe to reuse and must not retain request, response, user, + route, security token, locale, or error state. +- Ensure exceptions and early exits cannot skip cleanup needed before the next + request. +- For state-isolation fixes, prefer a regression test that exercises two + sequential requests or reset cycles in the same process and proves the + second request cannot observe the first request's state. +- If the template contract changes, update focused assertions in + `tests/system/Commands/Worker/WorkerCommandsTest.php`. +- If existing installations need the new generated entry point, update the + changelog and upgrading guide and instruct users to run + `php spark worker:install --force`. diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index 69cb0c51e123..000000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,23 +0,0 @@ -# https://github.com/actions/labeler?tab=readme-ov-file#usage - -# Add the `4.8` label to PRs that target the `4.8` branch. -'4.8': # @todo change value whenever the next minor version is changed -- base-branch: '4.8' - -# Add the `github_actions` label to PRs that change any file in the `.github/workflows/` directory. -'github_actions': -- changed-files: - - any-glob-to-any-file: - - '.github/workflows/*' - -# Add the `documentation` label to PRs for documentation only. -'documentation': -- changed-files: - - any-glob-to-all-files: - - 'user_guide_src/source/**' - -# Add the `testing` label to PRs that changes tests only. -'testing': -- changed-files: - - any-glob-to-all-files: - - 'tests/**' diff --git a/.github/mergeable.yml b/.github/mergeable.yml deleted file mode 100644 index 2e58526188aa..000000000000 --- a/.github/mergeable.yml +++ /dev/null @@ -1,69 +0,0 @@ -# https://mergeable.readthedocs.io/en/latest/configuration.html - -version: 2 -mergeable: - - when: issues.opened, issues.reopened - validate: - - do: or - validate: - - do: and - validate: - - do: description - must_include: - regex: '### PHP Version' - - do: description - must_include: - regex: '### CodeIgniter4 Version' - - do: author - must_include: - regex: ^kenjis|lonnieezell|MGatner|michalsn|paulbalandan|samsonasik|ddevsr$ - fail: - - do: comment - payload: - body: | - Hi there, @@author! :wave: - - It looks like you opened an issue without following the bug report template: - - * Bug report ([open an issue](https://github.com/codeigniter4/CodeIgniter4/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=Bug%3A+)) - * For feature request or support question, please use the [forums](https://forum.codeigniter.com/forum-30.html). - - The current issue will be closed. This is a precaution to save maintainers' time, I hope you'll understand. - - Sincerely, the mergeable bot 🤖 - - do: close - - - when: pull_request.opened, pull_request.ready_for_review - filter: - - do: payload - pull_request: - author_association: - must_include: - regex: ^NONE|FIRST_TIME_CONTRIBUTOR|FIRST_TIMER$ - validate: [] - pass: - - do: comment - payload: - body: | - Hi there, @@author! :wave: - - Thank you for sending this PR! - - We expect the following in all Pull Requests (PRs). - - PRs must be sent to the [appropriate branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#branching) - - All git commits must be [GPG-signed](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#signing) - - Must follow our [style guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#php-style) - - Be [commented](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#comments) in the PHP source file - - Be documented in the [user guide](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#user-guide) - - Be [unit tested](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#unit-testing) - - Pass all checks in GitHub Actions - - > [!IMPORTANT] - > We expect all code changes or bug-fixes to be accompanied by one or more tests added to our test suite to prove the code works. - - If pull requests do not comply with the above, they will likely be closed. Since we are a team of volunteers, we don't have any more time to work - on the framework than you do. Please make it as painless for your contributions to be included as possible. - - See https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md - - Sincerely, the mergeable bot 🤖 diff --git a/.github/prlint.json b/.github/prlint.json deleted file mode 100644 index bb352e554b71..000000000000 --- a/.github/prlint.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": [ - { - "pattern": "^(\\[\\d+\\.\\d+\\]\\s{1})?(feat|fix|chore|docs|perf|refactor|style|test|config|revert)(\\([\\-.@:`a-zA-Z0-9]+\\))?!?:\\s{1}\\S.+\\S|Prep for \\d\\.\\d\\.\\d release|\\d\\.\\d\\.\\d (Ready|Merge) code$", - "message": "PR title must include the type (feat, fix, chore, docs, perf, refactor, style, test, config, revert) of the commit per Conventional Commits specification. See https://www.conventionalcommits.org/en/v1.0.0/ for the discussion." - } - ] -} diff --git a/.github/release.yml b/.github/release.yml index de6e982d91a6..09ead7ca89d2 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -2,6 +2,7 @@ changelog: exclude: authors: - dependabot + - dependabot[bot] categories: - title: Breaking Changes labels: diff --git a/.github/scripts/random-tests-config.txt b/.github/scripts/random-tests-config.txt index 2435f6ea7a17..5bf0fce66733 100644 --- a/.github/scripts/random-tests-config.txt +++ b/.github/scripts/random-tests-config.txt @@ -11,7 +11,7 @@ API AutoReview Autoloader -# Cache +Cache CLI Commands Config @@ -20,14 +20,14 @@ Cookie # DataConverter # Database # Debug -# Email +Email # Encryption # Entity Events Files -# Filters +Filters Format -# HTTP +HTTP # Helpers Honeypot HotReloader diff --git a/.github/scripts/run-random-tests.sh b/.github/scripts/run-random-tests.sh index fe321bef0492..a0a962efbba5 100755 --- a/.github/scripts/run-random-tests.sh +++ b/.github/scripts/run-random-tests.sh @@ -41,8 +41,9 @@ readonly BOLD='\033[1m' readonly RESET='\033[0m' # Script paths -readonly script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -readonly project_root="$( cd "$script_dir/../.." && pwd )" +script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +project_root="$( cd "$script_dir/../.." && pwd )" +readonly script_dir project_root readonly config_file="$script_dir/random-tests-config.txt" readonly results_dir="$project_root/build/random-tests" @@ -166,7 +167,8 @@ inflect() { } generate_phpunit_random_seed() { - local seed=$(date +%s) + local seed + seed=$(date +%s) if [[ ! "$seed" =~ ^[0-9]+$ ]]; then echo 1 @@ -233,7 +235,8 @@ get_failed_test_predecessor() { print_result() { local type=$1 completed=$2 total=$3 component=$4 elapsed_str=$5 - local padded=$(printf "%${#total}d" "$completed") + local padded + padded=$(printf "%${#total}d" "$completed") local color symbol case "$type" in @@ -462,7 +465,8 @@ run_component_tests() { fi local test_dir="tests/system/$component" - local start_time=$(date +%s%N) + local start_time + start_time=$(date +%s%N) print_debug "Running tests for: $component" @@ -527,7 +531,8 @@ run_component_tests() { if kill -0 "$test_pid" 2>/dev/null; then touch "$timeout_marker" - local pids_to_kill=$(pgrep -P "$test_pid" 2>/dev/null) + local pids_to_kill + pids_to_kill=$(pgrep -P "$test_pid" 2>/dev/null) kill -TERM "$test_pid" 2>/dev/null || true if [[ -n "$pids_to_kill" ]]; then @@ -607,7 +612,8 @@ run_component_tests() { if [[ $exit_code -eq 124 ]]; then predecessor_info+=$'\nFailed test: (timeout before PHPUnit emitted failure event)' if [[ -f "$events_file" ]]; then - local last_prepared_test=$(extract_test_order "$events_file" | tail -n 1) + local last_prepared_test + last_prepared_test=$(extract_test_order "$events_file" | tail -n 1) if [[ -n "$last_prepared_test" ]]; then predecessor_info+=$'\nLast prepared test before timeout: '"${last_prepared_test}" else @@ -619,7 +625,8 @@ run_component_tests() { predecessor_info+=$'\nPrevious test: (unavailable due to timeout)' else if [[ -f "$events_file" ]]; then - local predecessor_result=$(get_failed_test_predecessor "$events_file") + local predecessor_result + predecessor_result=$(get_failed_test_predecessor "$events_file") if [[ -n "$predecessor_result" ]]; then local previous_test=${predecessor_result#*|} predecessor_info+=$'\nFailed test: '"${predecessor_result%%|*}" @@ -639,7 +646,7 @@ run_component_tests() { fi { - echo "> ${phpunit_args[@]:0:7}" + echo "> ${phpunit_args[*]:0:7}" echo "" echo "$output" echo "$predecessor_info" @@ -691,8 +698,9 @@ process_result() { first_result=false fi - local status=$(grep "^Exit code:" "$result_file" | sed 's/Exit code: //') - local elapsed_str=$(format_elapsed_time "$elapsed") + local status elapsed_str + status=$(grep "^Exit code:" "$result_file" | sed 's/Exit code: //') + elapsed_str=$(format_elapsed_time "$elapsed") case "$status" in 0) @@ -728,7 +736,8 @@ get_completed_components() { while IFS= read -r file_path; do # Remove prefix: random_test_result_ - local temp=$(basename "$file_path") + local temp + temp=$(basename "$file_path") temp=${temp#random_test_result_} # Extract elapsed time (everything before first underscore after number) @@ -749,13 +758,35 @@ get_completed_components() { # Sort entries by elapsed time numerically printf '%s\n' "${entries[@]}" | sort -t'|' -k1,1n | while IFS='|' read -r elapsed listed_component; do - if [[ ! " ${displayed_components[*]:-} " =~ " ${listed_component} " ]]; then + if [[ " ${displayed_components[*]:-} " != *" ${listed_component} "* ]]; then echo "$listed_component|$elapsed" fi done } +print_failed_component_details() { + local component=$1 + local result_file=$2 + + # GitHub Actions renders ::group:: lines as collapsible log sections. + if [[ -z "${GITHUB_ACTIONS:-}" || ! -f "$result_file" ]]; then + return + fi + + echo "::group::${component}: test result" + cat "$result_file" + echo "::endgroup::" + + local order_file="${result_file/random_test_result_/random_test_order_}" + + if [[ -f "$order_file" ]]; then + echo "::group::${component}: execution order" + cat "$order_file" + echo "::endgroup::" + fi +} + print_summary() { local run_number=$1 local pass_percent=0.00 @@ -779,13 +810,15 @@ print_summary() { if [[ $failed -gt 0 ]]; then echo -e "\n${BOLD_RED}Failed $(inflect "$failed" "Component" "Components"):${RESET}" while IFS= read -r failed_component; do - local result_file=$(find "$results_dir" -name "random_test_result_*_${failed_component}.txt" 2>/dev/null | head -n 1) + local result_file + result_file=$(find "$results_dir" -name "random_test_result_*_${failed_component}.txt" 2>/dev/null | head -n 1) if [[ -z "$result_file" ]]; then result_file="$results_dir/random_test_result_*_${failed_component}.txt" fi echo -e " ${RED}✗${RESET} ${BOLD}$failed_component${RESET} ($result_file)" + print_failed_component_details "$failed_component" "$result_file" done < <(printf '%s\n' "${failed_components[@]}" | sort) fi @@ -830,7 +863,7 @@ main() { # Read components from config file verify_config print_success "Configuration file: $config_file\n" - components_array=($(read_components)) + read -ra components_array <<< "$(read_components)" fi total=${#components_array[@]} diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index 3d24470ed846..8eb59179a946 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -30,13 +30,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: codeigniter4/api token: ${{ secrets.ACCESS_TOKEN }} diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml index 221383aafd57..1eeb86f772cb 100644 --- a/.github/workflows/deploy-distributables.yml +++ b/.github/workflows/deploy-distributables.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # fetch all tags persist-credentials: false @@ -50,13 +50,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: codeigniter4/framework token: ${{ secrets.ACCESS_TOKEN }} @@ -104,13 +104,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: codeigniter4/appstarter token: ${{ secrets.ACCESS_TOKEN }} @@ -158,13 +158,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: codeigniter4/userguide token: ${{ secrets.ACCESS_TOKEN }} @@ -172,7 +172,7 @@ jobs: persist-credentials: false - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index 9bbd7a5bb7ba..c943254325d6 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -36,7 +36,7 @@ jobs: coverage: none - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/label-add-conflict-all-pr.yml b/.github/workflows/label-add-conflict-all-pr.yml deleted file mode 100644 index 774012da1f32..000000000000 --- a/.github/workflows/label-add-conflict-all-pr.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Auto Label "stale" for All PRs - -on: - push: - branches: - - develop - - '4.*' - -jobs: - build: - name: Check Conflicts - - permissions: - contents: read - pull-requests: write - - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Get PR List - id: PR-list - run: echo "pr_list=$(gh pr list -L 100 --json mergeable,url,labels,author)" >> $GITHUB_OUTPUT - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: 'Add label "stale" and comment' - env: - PR_LIST: ${{ steps.PR-list.outputs.pr_list }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - - IFS=$'\n' # Set Internal Field Separator to newline to handle array elements - - # Iterate through the PRs in PR_LIST - for pr in $(echo "$PR_LIST" | jq -c '.[]'); do - mergeable=$(echo "$pr" | jq -r '.mergeable') - author=$(echo "$pr" | jq -r '.author.login') - labels=$(echo "$pr" | jq -c '.labels[].name' | tr -d '[]"') - url=$(echo "$pr" | jq -r '.url') - - # CONFLICTING and no 'stale' label - if [ "$mergeable" == "CONFLICTING" ] && [[ ! "$labels" == *"stale"* ]]; then - # Add "stale" label - gh pr edit $url --add-label "stale" - - # Add a comment - gh pr comment $url --body ":wave: Hi, @$author!

We detected conflicts in your PR against the base branch :speak_no_evil:
You may want to sync :arrows_counterclockwise: your branch with upstream!

Ref: [Syncing Your Branch](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/workflow.md#updating-your-branch)" - fi - done diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml deleted file mode 100644 index 2e2659c6963a..000000000000 --- a/.github/workflows/label-pr.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Add Labels to PRs - -# NOTE: When updating this workflow, you should first change the event to `pull_request` to test the changes -# in a PR, and then change it back to `pull_request_target` before merging. -# @see https://github.com/actions/labeler?tab=readme-ov-file#updating-major-version-of-the-labeler -on: - - pull_request_target - -jobs: - add-labels: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-24.04 - - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Verify PR source for workflow file changes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const prFiles = await github.paginate(github.rest.pulls.listFiles.endpoint.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - })); - const workflowFileChanged = prFiles.some(file => file.filename === '.github/workflows/label-pr.yml'); - - if (workflowFileChanged) { - if (context.payload.pull_request.head.repo.full_name !== 'codeigniter4/CodeIgniter4') { - throw new Error('Changes to label-pr.yml are not allowed from forks.'); - } - - console.log('Workflow file changed, but PR is from the main repository. Proceeding with label addition.'); - return; - } - - console.log('No changes to workflow file detected, proceeding with label addition.'); - - - name: Add labels - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 - with: - sync-labels: true # Remove labels when matching files are reverted diff --git a/.github/workflows/label-signing.yml b/.github/workflows/label-signing.yml deleted file mode 100644 index 80c321c5b117..000000000000 --- a/.github/workflows/label-signing.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Check Signed PR -on: - pull_request: - branches: - - 'develop' - - '4.*' - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - build: - name: Check Signed Commit - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Check signed commits in PR - uses: 1Password/check-signed-commits-action@ed2885f3ed2577a4f5d3c3fe895432a557d23d52 # v1.2.0 - with: - comment: | - You must GPG-sign your work, certifying that you either wrote the work or otherwise have the right to pass it on to an open-source project. See Developer's Certificate of Origin. See [signing][1]. - - **Note that all your commits must be signed.** If you have an unsigned commit, you can sign the previous commits by referring to [gpg-signing-old-commits][2]. - [1]: https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#signing - [2]: https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/workflow.md#gpg-signing-old-commits diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index e47cceb1771d..cf9c02b22e11 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 43d520458bf0..b216a71493bb 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -170,7 +170,7 @@ jobs: sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index 7858913cf531..8420d2b8c978 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -66,7 +66,7 @@ jobs: sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/run-carson.yml b/.github/workflows/run-carson.yml new file mode 100644 index 000000000000..a0a160b0930d --- /dev/null +++ b/.github/workflows/run-carson.yml @@ -0,0 +1,39 @@ +name: Carson + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + - reopened + - labeled + - ready_for_review + issues: + types: + - opened + - edited + - labeled + issue_comment: + types: + - created + push: + branches: + - develop + - '4.*' + schedule: + - cron: '0 0 * * *' + +permissions: {} + +jobs: + carson: + if: github.repository == 'codeigniter4/CodeIgniter4' + runs-on: ubuntu-24.04 + steps: + - name: Run Carson + uses: NexusPHP/carson@cb35604ab330c2e44dba673fcef712c78e75f858 # v1.7.0 + with: + app_id: ${{ secrets.CARSON_APP_ID }} + private_key: ${{ secrets.CARSON_PRIVATE_KEY }} + log_level: info diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index fd7c83248b2e..f06e02619966 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -36,7 +36,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index 125dbd5b3311..b02059733b13 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 diff --git a/.github/workflows/test-file-permissions.yml b/.github/workflows/test-file-permissions.yml index 7116f4620869..441d555137e8 100644 --- a/.github/workflows/test-file-permissions.yml +++ b/.github/workflows/test-file-permissions.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Detect unnecessary execution permissions run: php utils/check_permission_x.php diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index 586314917200..cc246f871808 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -46,7 +46,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index 43931032b9e4..3f385ce858fb 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml index 4f29559b2e93..e90de6bb031d 100644 --- a/.github/workflows/test-random-execution.yml +++ b/.github/workflows/test-random-execution.yml @@ -168,7 +168,7 @@ jobs: -Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8" - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index 4f52dfe53681..2fa9b74c0e02 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml index 3fcce79de33c..0000e700a422 100644 --- a/.github/workflows/test-scss.yml +++ b/.github/workflows/test-scss.yml @@ -33,10 +33,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' diff --git a/.github/workflows/test-structarmed.yml b/.github/workflows/test-structarmed.yml index 296b4d22870a..62a2dbc7d1cb 100644 --- a/.github/workflows/test-structarmed.yml +++ b/.github/workflows/test-structarmed.yml @@ -51,7 +51,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 @@ -59,6 +59,7 @@ jobs: php-version: ${{ matrix.php-version }} extensions: intl tools: composer + coverage: none - name: Validate composer.json run: composer validate --strict diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index 1dd382cd069b..88f316888f02 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml new file mode 100644 index 000000000000..0488b76a1a70 --- /dev/null +++ b/.github/workflows/verify-release.yml @@ -0,0 +1,185 @@ +# When a release is published, verify that the release pipeline delivered +# everything: the distributable repos, the new framework version on +# Packagist, and the user guide with its ePub. +# +# Use "Run workflow" with the release tag to verify an existing release. +name: Verify Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: The release tag to verify (e.g., v4.7.5) + type: string + required: true + +permissions: + contents: read + +env: + TAG: ${{ github.event.release.tag_name || inputs.version }} + PUBLISHED_AT: ${{ github.event.release.published_at }} + +jobs: + wait-for-deploy: + name: Wait for distributables deploy + if: github.repository == 'codeigniter4/CodeIgniter4' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + actions: read + contents: read + + steps: + - name: Wait for "Deploy Distributable Repos" to succeed + env: + GH_TOKEN: ${{ github.token }} + run: | + # On workflow_dispatch the event carries no publish time. + if [ -z "${PUBLISHED_AT}" ]; then + PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" --jq '.published_at') + echo "Resolved published_at=${PUBLISHED_AT} for ${TAG}." + fi + + query="repos/${{ github.repository }}/actions/workflows/deploy-distributables.yml/runs?event=release&per_page=1&created=>=${PUBLISHED_AT}" + + while true; do + run=$(gh api "${query}" --jq '.workflow_runs[0] | "\(.status) \(.conclusion)"' || true) + status=${run% *} + conclusion=${run#* } + echo "Deploy Distributable Repos: status=${status:-not found} conclusion=${conclusion:-n/a}" + + if [ "${status}" = "completed" ]; then + if [ "${conclusion}" = "success" ]; then + exit 0 + fi + + echo "Deploy Distributable Repos did not succeed." + exit 1 + fi + + sleep 30 + done + + appstarter: + name: Install and test appstarter + needs: wait-for-deploy + runs-on: ubuntu-24.04 + timeout-minutes: 45 + + steps: + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.2' + tools: composer + extensions: intl, gd + coverage: none + + - name: Wait for Packagist to serve the new framework version + run: | + version="${TAG#v}" + + for i in $(seq 1 30); do + if curl -fsSL https://repo.packagist.org/p2/codeigniter4/framework.json \ + | jq -e --arg version "v${version}" \ + '.packages."codeigniter4/framework" | map(select(.version == $version)) | length > 0' > /dev/null; then + echo "Packagist serves codeigniter4/framework v${version}." + exit 0 + fi + + echo "Attempt ${i}: v${version} is not yet on Packagist." + sleep 60 + done + + echo "Timed out waiting for Packagist to serve v${version}." + exit 1 + + - name: Install appstarter + run: composer create-project codeigniter4/appstarter release-test --no-interaction + + - name: Verify the framework version + working-directory: release-test + run: | + version="${TAG#v}" + installed=$(composer show codeigniter4/framework --format=json | jq -r '.versions[0]') + echo "Installed codeigniter4/framework: ${installed}" + + if [ "${installed}" != "v${version}" ]; then + echo "Expected v${version}." + exit 1 + fi + + - name: Test appstarter + working-directory: release-test + run: composer test + + userguide: + name: Verify user guide deployment + needs: wait-for-deploy + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Check that the ePub was added + env: + GH_TOKEN: ${{ github.token }} + run: | + version="${TAG#v}" + + for i in $(seq 1 30); do + if gh api "repos/codeigniter4/userguide/contents/CodeIgniter${version}.epub" --jq '.name' > /dev/null 2>&1; then + echo "CodeIgniter${version}.epub is in the userguide repo." + exit 0 + fi + + echo "Attempt ${i}: CodeIgniter${version}.epub is not yet in the userguide repo." + sleep 30 + done + + echo "Timed out waiting for CodeIgniter${version}.epub." + exit 1 + + - name: Check that the user guide workflows succeeded + env: + GH_TOKEN: ${{ github.token }} + run: | + # On workflow_dispatch the event carries no publish time. + if [ -z "${PUBLISHED_AT}" ]; then + PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" --jq '.published_at') + echo "Resolved published_at=${PUBLISHED_AT} for ${TAG}." + fi + + for workflow in "Deploy Production" "pages build and deployment"; do + query="repos/codeigniter4/userguide/actions/runs?per_page=30&created=>=${PUBLISHED_AT}" + + success="" + + for _ in $(seq 1 30); do + run=$(gh api "${query}" \ + | jq -r --arg name "${workflow}" \ + '[.workflow_runs[] | select(.name == $name)][0] | "\(.status) \(.conclusion)"') + status=${run% *} + conclusion=${run#* } + echo "${workflow}: status=${status:-not found} conclusion=${conclusion:-n/a}" + + if [ "${status}" = "completed" ]; then + if [ "${conclusion}" = "success" ]; then + success=1 + break + fi + + echo "\"${workflow}\" did not succeed." + exit 1 + fi + + sleep 30 + done + + if [ -z "${success}" ]; then + echo "Timed out waiting for \"${workflow}\"." + exit 1 + fi + done diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..208568025619 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,148 @@ +# CodeIgniter 4 — Repository Guidelines + +CodeIgniter 4 is a mature open-source PHP framework. Framework production +code lives in `system/`; its tests mirror that structure under +`tests/system/`. + +## Before making changes + +Consult the relevant sections of: + +- `contributing/pull_request.md` +- `contributing/internals.md` +- `contributing/styleguide.md` +- `tests/README.md` + +Follow existing code and tests in the affected component. Search for an +existing implementation before introducing a new abstraction or convention. + +## Target branch and compatibility policy + +Compatibility is judged against the pull request's base branch, not the +contributor's source or feature branch. When the base branch is unknown, +apply the stricter `develop` policy. + +### Base branch `develop` + +`develop` is the next patch-release line. Intentional backward-incompatible +changes are not allowed. + +- Preserve public and protected APIs, documented behavior, configuration + defaults, generated project files, exceptions, and observable side effects. +- Do not remove deprecated items or change public/protected signatures, + visibility, or interface/abstract requirements. +- A bug or security fix may correct faulty behavior but must preserve the + documented contract and ordinary valid usage. +- If a fix necessarily breaks compatibility, target the appropriate minor + branch instead. + +### Base branch `4.*` + +Minor-release lines (for example `4.9`). Small, deliberate compatibility +breaks may be accepted when they materially improve the framework or +complete the documented deprecation lifecycle. + +- Keep each break narrow, explicit, and justified; never an incidental + side effect of refactoring. +- Deprecated APIs may be removed no earlier than the second subsequent + minor release (deprecated in 4.6.x means removable in 4.8.0), with a + supported replacement. +- Every accepted break needs tests for the new behavior, a minor-version + changelog entry, and migration instructions in the upgrading guide. + +### All base branches + +- Production code must run on PHP 8.2. Do not require a newer PHP version, + even when a newer version is installed locally. +- Signatures, properties, constants, default values, exceptions, side + effects, configuration, and generated project files are all + compatibility-sensitive. +- Major-scale redesigns and ecosystem-wide migration requirements belong in + a major release, not a patch or minor release. +- Prefer the minimum useful abstraction and keep framework components as + independent as practical. +- Dependencies should be injectable. When a framework service is used as a + default, preserve the ability for applications to replace it. +- Do not add or update a Composer dependency unless the task explicitly + requires it and the change is justified. +- Do not modify code in `system/ThirdParty/`. + +## Coding standards + +- Do not add `declare(strict_types=1)` mechanically. Follow nearby code and + check the `DeclareStrictTypesRector` exclusions in `rector.php`; PHP files + use strict types unless excluded there. Treat the existing exclusions as + intentional compatibility constraints. Do not remove an exclusion without + dedicated justification and tests. +- Every new class property must have a native type declaration. Use the + most precise type supported by PHP 8.2; use `mixed` only when the + property intentionally accepts unrelated types. +- Do not add or change a type on an existing public or protected property + mechanically. Property types affect inheritance and must follow the + target-branch compatibility policy. +- Add PHPDoc only when it contributes information that native types cannot + express. Do not duplicate a parent or interface docblock. +- Never suppress a static-analysis error or update a baseline to make a + check pass. + +## FrankenPHP Worker Mode + +In Worker Mode one process serves many requests. The entry point's source +template is `system/Commands/Worker/Views/frankenphp-worker.php.tpl`; the +`worker:install` command publishes it as `public/frankenphp-worker.php`. + +- For every change affecting application bootstrap, request handling, + response sending, shutdown, superglobals, sessions, database or cache + connections, services, factories, events, toolbar state, static state, or + other request-lifecycle behavior, inspect the worker entry-point template + even when it is not part of the diff. +- Compare traditional per-process execution with worker execution, where + the framework boots once and handles multiple requests in the same + process. +- Classify mutable state as process-lifetime, intentionally persistent, or + request-specific. Request-specific state must be refreshed or reset for + every request and must not leak into the next request. +- Preserve the ordering requirements between per-request reconnection, + framework reset, superglobal refresh, application execution, and + post-request cleanup. +- Treat the template as the source of truth. Do not edit a generated + `public/frankenphp-worker.php` in place. +- When a release changes the template in a way existing installations must + receive, add an upgrading instruction telling Worker Mode users to + republish it with `php spark worker:install --force`. + +## Tests + +- Every bug fix should include a regression test that fails without the + fix. +- Test failure paths, exceptions, boundary conditions, and state cleanup, + not only the happy path. +- Prefer strict and dedicated PHPUnit assertions as described in the + project style guide. +- Do not weaken or remove an existing test unless the documented behavior + or specification changed. + +## Documentation + +- Public API, behavior, message, or default-value changes may require an + update to the user guide and changelog. +- Changes requiring user action, including configuration changes, may also + require an upgrading-guide entry. +- For a `4.*` target, every intentional compatibility break must be + documented in both the minor-version changelog and its upgrading guide. + +## Focused validation + +GitHub Actions is the authoritative source for full validation. Do not +reproduce the complete CI matrix locally. + +- Run only the narrowest PHPUnit test file or component that covers a code + change, for example + `vendor/bin/phpunit tests/system//Test.php`. +- Run a file-scoped analysis or formatting check only when it is quick and + directly relevant. +- Do not run the complete PHPUnit suite, `composer phpstan:check`, + `composer cs`, Psalm, Structarmed, or a repository-wide Rector analysis + by default. +- Report which focused checks were executed and which validation was left + to CI. diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d3974606b0..ddac0375df83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,159 @@ # Changelog +## [v4.7.5](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.5) (2026-10-09) +[Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.4...v4.7.5) + +### Security + +* **CURLRequest:** Request-specific headers, body, and options + are now cleared when a request throws an exception and ``shareOptions`` is ``false``. + Previously, reusing the client after a failed request could send credentials + or private data to a different destination. Options passed to the constructor + remain available for subsequent requests. + Per-request ``baseURI`` and ``delay`` values are also reset after successful + and failed requests, preventing URI components from leaking into later requests. + When retrying a failed request, pass its options again, or reapply settings + made with ``setAuth()``, ``setBody()``, ``setForm()``, or ``setJSON()``. + See the `Security advisory GHSA-9g9v-xgwj-697h `_ + for more information. +* **Validation:** The ``is_image`` and ``mime_in`` file upload rules now reject client filenames ending in dots while + continuing to accept genuinely extensionless uploads. These rules and ``ext_in`` also reject filenames with a PHP + handler extension before the final extension, such as ``shell.php.gif``, including extensions revealed by filename + sanitization. These checks also reject innocent filenames such as ``logo.php.gif``. See + :doc:`Upgrading from 4.7.4 to 4.7.5 ` for migration instructions. + Previously, a GIF/PHP polyglot with one of these names could pass validation. Applications that save uploads with + client-provided names in executable public directories may be affected. Use a generated filename or disable script + execution in the upload directory. See the `Security advisory GHSA-4hwf-v4mp-hf6c `_ + for more information. +* **Validation:** File validation rules now check every non-empty file in an + optional multiple upload. Previously, an entry with ``UPLOAD_ERR_NO_FILE`` + could cause ``max_size``, ``is_image``, ``mime_in``, ``ext_in``, ``max_dims``, + and ``min_dims`` to accept the upload without checking later files. + ``max_dims`` and ``min_dims`` also return ``false`` for upload errors other + than ``UPLOAD_ERR_NO_FILE`` before reading image dimensions. + See the `Security advisory GHSA-cwxp-v62r-xwx2 `_ + for more information. +* **View Parser:** Fixed a stored XSS vulnerability where a substituted value containing + Parser syntax (e.g., ``{text|raw}`` or ``{!text!}``) was interpreted by a later + substitution pass, allowing user-supplied data to bypass auto-escaping of other + pseudo-variables. Substituted values are now treated strictly as data and are never + parsed again. The same protection applies to output from built-in Parser plugins, + including URLs and validation messages. See :ref:`upgrade-475-view-parser-values` for more information. + See the `Security advisory GHSA-vgrf-mv2j-gp8w `_ + for more information. +* **View Parser:** Added restricted conditionals as an opt-in mitigation for + *Code Injection through Parser Template Source*. Conditions in ``{if ...}`` and + ``{elseif ...}`` tags are evaluated as PHP, so anyone who can edit Parser template + source can execute PHP code. This remains the default behavior. Applications that + let less-trusted users edit Parser templates must enable the new + ``Config\View::$restrictParserConditionals`` setting or the ``restrictConditionals`` + render option. Restricted conditionals only limit this execution path; they do not + make every template feature safe for less-trusted authors. + The restriction also applies to nested renders on the same Parser instance. + See :ref:`parser-restricting-conditionals` and the + `Security advisory GHSA-4q58-jw8x-8cm7 `_ + for more information. +* **Views:** Fixed a code execution vulnerability caused by view data keys + overriding renderer-local variables. Data named ``template`` in ``Parser`` or + ``view`` in ``View::renderString()`` could replace the source evaluated as PHP. + Data named ``foundView`` in view cells could replace the selected file path + and cause unintended local file inclusion. These issues require applications + to pass less-trusted data under the affected keys. + See the `Security advisory GHSA-c29x-ffjj-8r7x `_ + for more information. + +### Fixed Bugs + +* fix: safely interpolate logger context values by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10384 +* fix: resolve global state pollution causing random-order test failures in HTTP suite by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10372 +* fix: omit empty CSP headers by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10412 +* fix: remove destructive setServerArray([]) in FiltersTest::setUp to prevent random-order test failures by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10425 +* fix: resolve TypeError in CLIRequest::parseCommand when argv is missing by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10415 +* fix: resolve race conditions in Redis TTL and prevent cache test state leakage by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10374 +* fix: handle null results in get_dir_file_info() by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10407 +* fix: skip unreadable `.env` in `Boot::loadDotEnv` for separate-process tests by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10417 +* fix: return 403 for Honeypot bot detection by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10490 +* fix: pass prompt text to readline in `CLI::prompt()` so backspace does not erase it by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10507 +* fix(Cookie): validate raw cookie values by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10516 +* fix(CodeIgniter): prevent gatherOutput from being called twice when controller returns Response by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10369 +* fix: Memcached decrement() gives the wrong sign on a missing key by @mdalikadar in https://github.com/codeigniter4/CodeIgniter4/pull/10512 +* fix: correct file permissions and chmod target in File and UploadedFile move() by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10519 +* fix: strip the table prefix correctly when rebuilding a SQLite3 table by @karlgray in https://github.com/codeigniter4/CodeIgniter4/pull/10509 +* fix: write the prompt to STDOUT before readline() on Windows by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10534 +* fix(Cookie): validate cookie path and domain attributes by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10527 +* fix(I18n): compute correct date in Time::today(), yesterday(), and tomorrow() across timezones by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10532 +* fix: return an empty string when readline() reaches end-of-file by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10542 +* fix: reject unsupported database drivers in `make:migration --session` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10546 +* fix(Test): reset `is_windows()` mock state in `CIUnitTestCase::tearDown()` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10554 +* fix(Cache): set secure 0755 permissions mode when creating directory in `FileVarExportHandler` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10555 +* fix: stop `FileLocatorCached` from restoring a deleted cache on shutdown by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10559 +* fix: preserve sub-second precision in `BaseModel` datetime timestamps by @wakqasahmed in https://github.com/codeigniter4/CodeIgniter4/pull/10561 +* fix(Debug): treat zero time as valid timer start by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10569 +* fix: reject non-digit characters in `valid_cc_number()` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10580 +* fix: throw Postgre query errors with warnings disabled by @wakqasahmed in https://github.com/codeigniter4/CodeIgniter4/pull/10573 +* fix: prevent infinite loop in `word_wrap()` with a character limit below 2 by @mdalikadar in https://github.com/codeigniter4/CodeIgniter4/pull/10596 +* fix: resolve schema-qualified table names in `getFieldData()` and `protectIdentifiers()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10604 +* fix: cast Postgre batch subquery values so `updateBatch()` and `deleteBatch()` accept mixed PHP types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10601 +* fix: cast OCI8 batch subquery values so `updateBatch()` and `deleteBatch()` accept mixed PHP types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10602 +* fix: avoid undefined `STDERR` and `STDIN` outside the CLI by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10605 + +### Refactoring + +* refactor(HTTP): split setCURLOptions into category helper methods to reduce complexity by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10339 +* refactor: use `array-key` as the benevolent union of `int|string` in array shapes by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10396 +* refactor: Enable Rector Code Quality set by @samsonasik in https://github.com/codeigniter4/CodeIgniter4/pull/10429 +* refactor: fix uses of `empty()` calls by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10439 +* refactor: fix `method.alreadyNarrowedType` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10440 +* refactor: cleanup the Images library by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10441 +* refactor: add generics to `Entity` and `DataCaster` casts by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10442 +* refactor: fix `ternary.shortNotAllowed` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10445 +* refactor: fix `missingType.property` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10447 +* refactor: fix `return.type` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10448 +* refactor: add generics to `Forge`, `BaseBuilder` and `BasePreparedQuery` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10449 +* refactor: fix `nullCoalesce.property` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10450 +* refactor: add precise return type to `CodeIgniter::getPerformanceStats()` by @soccerlover29 in https://github.com/codeigniter4/CodeIgniter4/pull/10458 +* refactor: fix `property.nonObject` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10451 +* refactor: sidestep PhpStorm warning on `@method` docblock in `Model` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10444 +* refactor: fix phpstan errors in `IncomingRequestTest` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10452 +* refactor: fix phpstan errors in `Helpers` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10459 +* refactor: fix phpstan errors in `Router` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10460 +* refactor: clear out phpstan errors in `Validation` source and tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10461 +* refactor: clear out phpstan errors in `HTTP` source and tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10462 +* refactor: fix phpstan errors in `BaseBuilder` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10466 +* refactor: add precise parameter types to Router test fixtures by @soccerlover29 in https://github.com/codeigniter4/CodeIgniter4/pull/10464 +* refactor: fix phpstan errors in `Forge` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10470 +* refactor: fix phpstan errors in `Result` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10473 +* refactor: fix phpstan errors in `Connection` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10475 +* refactor: fix phpstan errors in `PreparedQuery` and `Utils` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10476 +* refactor: fix phpstan errors in `Config`, `Database`, `Query`, `MigrationRunner`, and `SQLite3\Table` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10477 +* refactor: fix phpstan errors in `Debug` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10478 +* refactor: fix phpstan errors in `Test` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10479 +* refactor: widen `Response` body and `Formatter` data typing to `mixed` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10480 +* refactor: fix remaining errors in `Helpers`' tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10481 +* refactor: fix phpstan errors in `Config` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10482 +* refactor: fix phpstan errors in `Commands` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10483 +* refactor: fix phpstan errors in `View` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10484 +* refactor: fix phpstan errors in `Filters` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10485 +* refactor: fix phpstan errors in `Encryption` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10486 +* refactor: fix iterable types in `Email` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10487 +* refactor: fix iterable types in `Router` tests by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10488 +* refactor: fix remaining phpstan errors in `Database` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10492 +* refactor: fix phpstan errors in `I18n` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10493 +* refactor: fix phpstan errors in `DataConverter` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10494 +* refactor: fix phpstan errors in `AutoReview` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10495 +* refactor: fix missing parameter types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10496 +* refactor: fix remaining missing iterable types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10497 +* refactor: fix remaining not found methods by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10498 +* refactor: fix the remaining fixable `assign.propertyType` and `phpdoc.propertyType` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10499 +* refactor: fix remaining `argument.type` and `method.childParameterType` errors by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10502 +* refactor: replace anonymous class with bound closure in `PropertiesTrait` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10504 +* refactor: bound phpstan's analysed PHP versions by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10505 +* refactor: Enable MustHaveReturnTypeFunctionRule StructArmed rule on system/Helpers by @samsonasik in https://github.com/codeigniter4/CodeIgniter4/pull/10521 +* refactor: fix minor type inaccuracies found via PHPStan bleeding edge by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10522 +* refactor: fix return type covariance of `FileCollection::getIterator()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10558 +* refactor: tighten docblock return types in Result, CLIRequest, url_helper and IncomingRequest by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10588 +* refactor: pass the session TTL to `Redis::set()` as an options array by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10600 + ## [v4.7.4](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.4) (2026-07-07) [Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.3...v4.7.4) diff --git a/admin/README.md b/admin/README.md index 1f5dd89bfcd1..a4048bf59152 100644 --- a/admin/README.md +++ b/admin/README.md @@ -4,33 +4,33 @@ This folder contains tools or docs useful for project maintainers. ## Repositories inside https://github.com/codeigniter4 -- **CodeIgniter4** is the main development repository. - It supports issues and pull requests, and has a rule to enforce GPG-signed commits. - In addition to the framework source, it includes unit testing and documentation source. - The three repositories following are built from this one as part of the release workflow. +- **CodeIgniter4** is the main development repository. + It supports issues and pull requests, and has a rule to enforce GPG-signed commits. + In addition to the framework source, it includes unit testing and documentation source. + The three repositories following are built from this one as part of the release workflow. This repo is meant to be forked by contributors. -- **framework** is the released developer repository. +- **framework** is the released developer repository. It contains all the main pieces of the framework that developers would use to - build their apps, but not the framework unit testing or the user guide source. - It is meant to be downloaded by developers, or composer-installed. + build their apps, but not the framework unit testing or the user guide source. + It is meant to be downloaded by developers, or composer-installed. This is a read-only repository. -- **appstarter** is the released application starter repository. +- **appstarter** is the released application starter repository. It is derived from the framework's `app` and `public` folders, with - a composer requirement dependency to pull in the framework itself. - It is meant to be downloaded or composer-installed. + a composer requirement dependency to pull in the framework itself. + It is meant to be downloaded or composer-installed. This is a read-only repository. -- **userguide** is released documentation publishing repository. +- **userguide** is released documentation publishing repository. It contains built versions of the user guide, corresponding to the - framework releases. - It could be downloaded, forked or potentially composer-installed. + framework releases. + It could be downloaded, forked or potentially composer-installed. This is a read-only repository. -- **coding-standard** is the coding style standards repository. +- **coding-standard** is the coding style standards repository. It contains PHP-CS-Fixer rules to ensure consistent code style - within the framework itself. + within the framework itself. It is meant to be composer-installed. - **translations** is the repository holding official translations of - the locale-dependent system messages. - It is community-maintained, and accepts issues and pull requests. + the locale-dependent system messages. + It is community-maintained, and accepts issues and pull requests. It could be downloaded, forked or composer-installed. ## Contributor Scripts @@ -41,47 +41,41 @@ This folder contains tools or docs useful for project maintainers. to be added as part of a git commit, ensuring that they conform to the framework coding style standards, and automatically fixing what can be. -## Maintainer Scripts +## Release Scripts -- **release-config** holds variables used for the maintainer & release building -- **docbot** re-builds the user guide from the RST source for it, - and optionally deploys it to the `gh-pages` branch of the main - repository (if the user running it has maintainer rights on that repo). - See the [writeup](./docbot.md). - -## Release Building Scripts - -*Do not use these scripts! They are left here for reference only.* - -The release workflow is detailed in its own writeup; these are the main -scripts used by the release manager: - -- **release** builds a new release branch in the main repo, for vetting. - This includes updating version dependencies or constants, - generating version(s) of the user guide; and possibly - moving or ignoring stuff, distinguishing release from development. - If successful, it will update the `config` file, with the version number - in it, and it will run the related scripts following, to revise - the release distributions. - Usage: `admin/release version qualifier` -- **release-userguide** builds the distributable userguide repo. - It could be used on its own, but is normally part of `release`. -- **release-deploy** pushes the release changes to the appropriate github - repositories. Tag & create releases on GitHub. This is not easily reversible! - Usage: `admin/release-deploy version qualifier` -- **release-revert** can be used to restore your repositories to the state they - were in before you started a release. **IF** you haven't deployed. - This is in case you decide not to proceed with the release, for any reason. - Remember to be polite when running it. +The release process is detailed in [RELEASE.md](./RELEASE.md). These scripts +are used as part of that process: +- **generate-changelog.php** prepends the CHANGELOG.md entry for a new + release, built from GitHub's auto-generated release notes and the + SECURITY section of the version's detailed changelog. + Usage: `php admin/generate-changelog.php 4.x.x [--dry-run]` +- **prepare-release.php** creates the `release-4.x.x` branch and updates + version references in the framework source, the user guide, and the + distribution build script. + Usage: `php admin/prepare-release.php 4.x.x` +- **create-new-changelog.php** creates the changelog and upgrade guide + stubs for the next version and adds them to their index files. + Usage: `php admin/create-new-changelog.php ` +- **check-pr-labels.php** flags PRs merged after a release (default: the + latest) that appear to be missing the labels used to generate the + changelog. + Usage: `php admin/check-pr-labels.php []` +- **update-upgrade-guide.php** fills the "Config" and "All Changes" sections + of the version's upgrade guide with the project space files changed since + the last release. + Usage: `php admin/update-upgrade-guide.php [--dry-run]` +- **sync-release-branches.php** checks out the target branch and merges + upstream and the source branch into it, as done at several points of the + release process. Without `--push`, the result is left unpushed for review. + Usage: `php admin/sync-release-branches.php [--push]` ## Other Stuff -- **release-notes.bb** is a boilerplate for forum announcements of a new release. - It is marked up using [BBcode](https://en.wikipedia.org/wiki/BBCode). - The **framework** and **starter** subfolders contain files that will over-ride those from the development repository, when the distribution repositories are built. -- The subfolders inside `admin` contain "next release" files in the case of +- The subfolders inside `admin` contain "next release" files in the case of `codeigniter4` and over-written distribution files in the other cases. -- The CHANGELOG.md file is auto-generated using the [GitHub Changelog Generator](https://github.com/github-changelog-generator/github-changelog-generator) +- The CHANGELOG.md file is generated from GitHub's auto-generated release + notes, as described in [RELEASE.md](./RELEASE.md). diff --git a/admin/RELEASE.md b/admin/RELEASE.md index 75b4e26b2b31..75f2a0fbf35a 100644 --- a/admin/RELEASE.md +++ b/admin/RELEASE.md @@ -1,11 +1,10 @@ # Release Process -> Documentation guide based on the releases of `4.0.5` and `4.1.0` on January 31, 2021. +> Documentation guide based on the releases of `4.0.5` and `4.1.0` on January 31, 2021 (MGatner) > -> Updated for `4.5.0` on April 7, 2024. -> Updated for `4.6.0` on January 19, 2025. -> -> -MGatner, kenjis +> Updated for `4.5.0` on April 7, 2024 (kenjis) +> Updated for `4.6.0` on January 19, 2025 (paulbalandan) +> Updated for `4.7.5` on July 8, 2026 (paulbalandan) ## Notation @@ -18,17 +17,14 @@ ## Merge `develop` branch into next minor version branch `4.y` -Before starting release process, if there are commits in `develop` branch that +Before starting the release process, if there are commits in `develop` branch that are not merged into `4.y` branch, merge them. This is because if conflicts occur, merging will take time. -```console -git fetch upstream -git switch 4.y -git merge upstream/4.y -git merge upstream/develop -git push upstream HEAD -``` +* [ ] Merge `develop` into `4.y`: + ```console + php admin/sync-release-branches.php 4.y develop --push + ``` ## [Minor version only] Merge minor version branch into `develop` @@ -73,23 +69,33 @@ the following [labels](https://github.com/codeigniter4/CodeIgniter4/labels): PRs with breaking changes must have the following additional label: - **breaking change** ... PRs that may break existing functionalities -### Generate Changelog +At any time, missing labels can be checked for with +`php admin/check-pr-labels.php`, which flags PRs merged since the last release +that appear to lack a changelog label. It requires the authenticated +[GitHub CLI](https://cli.github.com/). -To auto-generate the changelog, navigate to the -[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page, -click the "Draft a new release" button. +### Generate Changelog -* Choose a tag: `v4.x.x` (Create new tag: v4.x.x on publish) -* Target: `develop` +The changelog is generated from GitHub's auto-generated release notes with a +script. It requires the authenticated [GitHub CLI](https://cli.github.com/). -Click the "Generate release notes" button. +The script also copies the security fixes from the SECURITY section of +**user_guide_src/source/changelogs/v4.x.x.rst**, so any security advisory PRs +must be merged and documented there before generating the changelog. -Check the resulting content. If there are items in the *Others* section which -should be included in the changelog, add a label to the PR and regenerate -the changelog. +* [ ] Run `php admin/generate-changelog.php 4.x.x --dry-run` and check the output. + * The script lists the PRs that have none of the changelog labels. If any of + them should be included in the changelog, add a label to the PR and run + the script again. +* [ ] Run `php admin/generate-changelog.php 4.x.x` to prepend the new entry to + **CHANGELOG.md**. -Copy the resulting contents into **CHANGELOG.md** and adjust the format to match -the existing content. +If the script cannot be used, the same notes can be generated from the +[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page: +click "Draft a new release", choose the tag `v4.x.x` (create new tag on +publish) with target `develop`, and click "Generate release notes". Copy the +resulting contents into **CHANGELOG.md** and adjust the format to match the +existing content. ## Process @@ -99,18 +105,15 @@ the existing content. > generating much new content. * [ ] Merge any security advisory PRs in private forks. -* [ ] Add the current version to **CHANGELOG.md** with the contents generated above. +* [ ] Check that **CHANGELOG.md** contains the entry for the new version, generated above. * [ ] Update **user_guide_src/source/changelogs/v4.x.x.rst** * Remove the section titles that have no items * [ ] Update **user_guide_src/source/installation/upgrade_4xx.rst** - * [ ] fill in the "All Changes" section using the following command, and add it to **upgrade_4xx.rst**: - ``` - git diff --name-status upstream/master -- . ':!.github/' ':!admin/' ':!changelogs/' ':!contributing/' \ - ':!system/' ':!tests/' ':!user_guide_src/' ':!utils/' \ - ':!*.json' ':!*.xml' ':!*.dist' ':!rector.php' ':!structarmed.php' \ - ':!phpstan*' ':!psalm*' ':!.php-cs-fixer.*' ':!LICENSE' ':!CHANGELOG.md' - ``` - * Note: `tests/` is not used for distribution repos. See `admin/starter/tests/`. + * [ ] Run `php admin/update-upgrade-guide.php 4.x.x` to fill in the "Config" and + "All Changes" sections with the project space files changed since the last release. + * The "Config" section is not modified if it already has content. The script + prints any missing entries to merge manually. + * Add notes to the "Config" entries as needed. * [ ] Remove the section titles that have no items * [ ] [Minor version only] Update the "from" version in the title, (e.g., `from 4.3.x` → `from 4.3.8`). * [ ] Run `php admin/prepare-release.php 4.x.x` and push to origin. @@ -118,16 +121,16 @@ the existing content. * Create a new branch `release-4.x.x` * Update **system/CodeIgniter.php** with the new version number: `const CI_VERSION = '4.x.x';` - * Update **user_guide_src/source/conf.py** with the new `version = '4.x'` (if releasing - the minor version) and `release = '4.x.x'`. + * Update **user_guide_src/source/conf.py** with the new `version = '4.x'` + and `release = '4.x.x'`. * Update **user_guide_src/source/changelogs/{version}.rst** * Set the date to format `Release Date: January 31, 2021` * Update **phpdoc.dist.xml** with the new `CodeIgniter v4.x API` and `` * Update **admin/starter/builds**: * Set `define('LATEST_RELEASE', '^4.x')` - * Set `define('NEXT_MINOR', '4.y-dev')`. - * If the major version changes, you need to manually change to `define('NEXT_MINOR', '5.0-dev')`. + * Set `define('NEXT_MINOR', '^4.y-dev')`. + * If the major version changes, you need to manually change to `define('NEXT_MINOR', '^5.0-dev')`. * Commit the changes with `Prep for 4.x.x release` * [ ] Create a new PR from `release-4.x.x` to `develop`: * Title: `Prep for 4.x.x release` @@ -163,37 +166,23 @@ the existing content. **Full Changelog**: https://github.com/codeigniter4/CodeIgniter4/compare/v4.x.w...v4.x.x ``` Click the "Generate release notes" button, and get the "New Contributors". -* [ ] Watch for the "Deploy Distributable Repos" action to make sure **framework**, - **appstarter**, and **userguide** get updated -* [ ] Run the following commands to install and test `appstarter` and verify the new - version: - ```console - rm -rf release-test - composer create-project codeigniter4/appstarter release-test - cd release-test - composer test && composer info codeigniter4/framework - ``` -* [ ] Verify that the user guide actions succeeded: - * [ ] "[Deploy Distributable Repos](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/deploy-distributables.yml)", the main repo - * [ ] "[Deploy Production](https://github.com/codeigniter4/userguide/actions/workflows/deploy.yml)", UG repo - * [ ] "[pages-build-deployment](https://github.com/codeigniter4/userguide/actions/workflows/pages/pages-build-deployment)", UG repo - * [ ] Check if "CodeIgniter4.x.x.epub" is added to UG repo. "CodeIgniter.epub" was - created when v4.3.8 was released. +* [ ] Watch the "[Verify Release](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/verify-release.yml)" + workflow and make sure it passes. It does the following: + * Wait for the "[Deploy Distributable Repos](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/deploy-distributables.yml)" + action to update **framework**, **appstarter**, and **userguide** + * Install `appstarter` from Packagist, verify it pulls framework `v4.x.x`, + and run its tests + * Verify that the user guide deployments succeeded ("Deploy Production" and + "pages build and deployment" in the UG repo) and that **CodeIgniter4.x.x.epub** + was added + * If it fails, fix the cause and re-run it via "Run workflow" with the tag `v4.x.x`. * [ ] Fast-forward `develop` branch to catch the merge commit from `master` ```console - git fetch upstream - git checkout develop - git merge upstream/develop - git merge upstream/master - git push upstream HEAD + php admin/sync-release-branches.php develop master --push ``` * [ ] Update the next minor version branch `4.y`: ```console - git fetch upstream - git switch 4.y - git merge upstream/4.y - git merge upstream/develop - git push upstream HEAD + php admin/sync-release-branches.php 4.y develop --push ``` * [ ] [Minor version only] Create the new next minor version branch `4.z`: ```console @@ -234,26 +223,31 @@ sudo pip3 install sphinx_rtd_theme ### Manual User Guide Process -* Still in the **CodeIgniter4** repo enter the **user_guide_src** directory -* Clear out any old build files: `rm -rf build/` -* Build the HTML version of the User Guide: `make html` -* Build the ePub version of the User Guide: `make epub` -* Switch to the **userguide** repo and create a new branch `release-4.x.x` -* Replace **docs/** with **CodeIgniter4/user_guide_src/build/html** -* Ensure the file **docs/.nojekyll** exists or GitHub Pages will ignore folders +> [!NOTE] +> This process is normally not needed. The "Deploy Distributable Repos" action +> builds and deploys the User Guide automatically when a release is published. +> Follow these steps only if the automatic deployment fails. + +* [ ] Still in the **CodeIgniter4** repo enter the **user_guide_src** directory +* [ ] Clear out any old build files: `rm -rf build/` +* [ ] Build the HTML version of the User Guide: `make html` +* [ ] Build the ePub version of the User Guide: `make epub` +* [ ] Switch to the **userguide** repo and create a new branch `release-4.x.x` +* [ ] Replace **docs/** with **CodeIgniter4/user_guide_src/build/html** +* [ ] Ensure the file **docs/.nojekyll** exists or GitHub Pages will ignore folders with an underscore prefix -* Copy **CodeIgniter4/user_guide_src/build/epub/CodeIgniter.epub** to +* [ ] Copy **CodeIgniter4/user_guide_src/build/epub/CodeIgniter.epub** to **./CodeIgniter4.x.x.epub** -* Commit the changes with "Update for 4.x.x" and push to origin -* Create a new PR from `release-4.x.x` to `develop`: +* [ ] Commit the changes with "Update for 4.x.x" and push to origin +* [ ] Create a new PR from `release-4.x.x` to `develop`: * Title: "Update for 4.x.x" * Description: blank -* Merge the PR -* Create a new Release: +* [ ] Merge the PR +* [ ] Create a new Release: * Version: "v4.x.x" * Title: "CodeIgniter 4.x.x User Guide" * Description: "CodeIgniter 4.x.x User Guide" -* Watch for the "github pages" Environment to make sure the deployment succeeds +* [ ] Watch for the "github pages" Environment to make sure the deployment succeeds The User Guide website should update itself via the deploy GitHub Action. Should this fail the server must be updated manually. See repo and hosting details in diff --git a/admin/alldocs b/admin/alldocs deleted file mode 100755 index 12d0b410ff84..000000000000 --- a/admin/alldocs +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# Rebuild and deploy all CodeIgniter4 docs -# - -. admin/docbot $1 -. admin/apibot $1 \ No newline at end of file diff --git a/admin/apibot b/admin/apibot deleted file mode 100755 index 08b451a04bbb..000000000000 --- a/admin/apibot +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -# Rebuild and deploy CodeIgniter4 under-development user guide -# -# This is a quick way to test user guide changes, and if they -# look good, to push them to the gh-pages branch of the -# development repository. -# -# This is not meant for updating the "stable" user guide. - -UPSTREAM=https://github.com/codeigniter4/api.git - -# Prepare the nested repo clone folder -rm -rf build/api* -mkdir -p build/api/docs - -# Get ready for git -cd build/api -git init -git remote add origin $UPSTREAM -git fetch -git checkout master -git reset --hard origin/master -rm -r docs/* - -# Make the new user guide -cd ../.. -phpdoc -cp -R api/build/* build/api/docs - -# All done? -if [ $# -lt 1 ]; then - exit 0 -fi - -# Optionally update the remote repo -if [ $1 = "deploy" ]; then - cd build/api - git add . - git commit -S -m "APIbot synching" - git push -f origin master -fi diff --git a/admin/apibot.md b/admin/apibot.md deleted file mode 100644 index ba63aaf269fa..000000000000 --- a/admin/apibot.md +++ /dev/null @@ -1,36 +0,0 @@ -# apibot - -Builds & deploys API docs. - -The in-progress CI4 API docs, warts & all, are rebuilt and -then copied to a nested -repository clone (`build/api`), with the result -optionally pushed to the `master` branch of the `api` repo. -That would then be publicly visible as the in-progress -version of the [API](https://codeigniter4.github.io/api/). - -## Requirements - -You must have phpDocumentor installed, with a `phpdoc` alias installed globally. - -## Audience - -This script is intended for use by framework maintainers, -i.e. someone with commit rights on the CI4 repository. - -You will be prompted for your github credentials and -GPG-signing key as appropriate. - -## Usage - -Inside a shell prompt, in the project root: - - `admin/apibot [deploy]` - -If "deploy" is not added, the script execution is considered -a trial run, and nothing is pushed to the repo. - -Whether or not deployed, the results are left inside -`build/api` (which is git ignored). - -Generate these and the userguide together with the 'alldocs' script. diff --git a/admin/check-pr-labels.php b/admin/check-pr-labels.php new file mode 100644 index 000000000000..a1a2f7d9c33e --- /dev/null +++ b/admin/check-pr-labels.php @@ -0,0 +1,203 @@ +&1", $repo, $number), $files, $exitCode); + + if ($exitCode !== 0) { + return true; + } + + foreach ($files as $file) { + if (str_starts_with($file, 'system/')) { + return true; + } + } + + return false; +} + +chdir(__DIR__ . '/..'); + +$ansi = stream_isatty(STDOUT); +$repo = 'codeigniter4/CodeIgniter4'; + +// Labels used by the changelog categories. See ".github/release.yml". +$changelogLabels = ['breaking change', 'bug', 'enhancement', 'new feature', 'refactor']; + +// PR title types that map to a changelog label. +$typeToLabel = [ + 'fix' => 'bug', + 'feat' => 'new feature', + 'perf' => 'enhancement', + 'refactor' => 'refactor', +]; + +// PR title types that need no changelog label. +$typesWithoutLabel = ['chore', 'ci', 'docs', 'style', 'test']; + +// Release process PRs carry no labels. +$releaseTitles = '/\A(?:Prep for \d+\.\d+\.\d+ release|\d+\.\d+\.\d+ (?:Ready|Merge) code)\z/'; + +$tag = null; + +foreach (array_slice($argv, 1) as $arg) { + if (preg_match('/\Av?(\d+\.\d+\.\d+)\z/', $arg, $matches) === 1) { + $tag = "v{$matches[1]}"; + + continue; + } + + echo sprintf("Usage: php %s [version]\n", $argv[0]); + echo sprintf("E.g.,: php %s 4.7.3\n", $argv[0]); + echo "Checks the PRs merged after the given release. Defaults to the latest release.\n"; + + exit(1); +} + +$endpoint = $tag === null ? sprintf('repos/%s/releases/latest', $repo) : sprintf('repos/%s/releases/tags/%s', $repo, $tag); +exec(sprintf('gh api %s 2>&1', $endpoint), $output, $exitCode); + +if ($exitCode !== 0) { + echo sprintf("Failed to fetch the release %s from GitHub:\n", $tag ?? '(latest)'); + echo implode("\n", $output) . "\n"; + + exit(1); +} + +$release = json_decode(implode("\n", $output), true); +$since = $release['published_at'] ?? ''; + +echo sprintf("Checking PRs merged since %s (%s).\n", $release['tag_name'], $since); + +$command = sprintf( + 'gh pr list --repo %s --search %s --json number,title,labels,baseRefName,url --limit 300 2>&1', + $repo, + escapeshellarg(sprintf('is:merged -base:master merged:>%s', $since)), +); +exec($command, $prOutput, $exitCode); + +if ($exitCode !== 0) { + echo "Failed to fetch the merged PRs from GitHub:\n"; + echo implode("\n", $prOutput) . "\n"; + + exit(1); +} + +$pulls = json_decode(implode("\n", $prOutput), true); + +if (! is_array($pulls)) { + echo "Unexpected response from GitHub.\n"; + + exit(1); +} + +echo sprintf("Found %d merged PRs.\n\n", count($pulls)); + +$missingLabel = []; +$needManualLook = []; + +foreach ($pulls as $pull) { + $labels = array_column($pull['labels'], 'name'); + $hasLabel = array_intersect($changelogLabels, $labels) !== []; + $type = null; + + if (preg_match('/\A(\w+)(?:\([^)]*\))?:/', $pull['title'], $matches) === 1) { + $type = $matches[1]; + } + + if ($hasLabel || in_array($type, $typesWithoutLabel, true)) { + continue; + } + + if (preg_match($releaseTitles, $pull['title']) === 1) { + continue; + } + + if ($type !== null && isset($typeToLabel[$type])) { + // The refactor label applies only to refactoring in system/. Test-only + // refactors may use the testing label, which is not in the changelog. + if ($type === 'refactor' && ! touches_system_dir($repo, (int) $pull['number'])) { + continue; + } + + $missingLabel[] = [ + 'number' => $pull['number'], + 'title' => $pull['title'], + 'base' => $pull['baseRefName'], + 'url' => $pull['url'], + 'label' => $typeToLabel[$type], + ]; + } else { + $needManualLook[] = [ + 'number' => $pull['number'], + 'title' => $pull['title'], + 'base' => $pull['baseRefName'], + 'url' => $pull['url'], + ]; + } +} + +if ($missingLabel !== []) { + echo color('PRs that appear to be missing a changelog label:', '1', $ansi) . "\n"; + + foreach ($missingLabel as $pull) { + echo sprintf("%s [suggested: %s]\n", format_pull_line($pull, $ansi), color($pull['label'], '33', $ansi)); + } + + echo "\nTo add the suggested labels, run the following commands (drop any that are not warranted):\n"; + + foreach ($missingLabel as $pull) { + echo sprintf("gh pr edit %d --repo %s --add-label \"%s\"\n", $pull['number'], $repo, $pull['label']); + } + + echo "\n"; +} + +if ($needManualLook !== []) { + echo color('PRs with no changelog label and no recognized title type (check manually):', '1', $ansi) . "\n"; + + foreach ($needManualLook as $pull) { + echo format_pull_line($pull, $ansi) . "\n"; + } + + echo "\n"; +} + +if ($missingLabel === []) { + echo color('No PRs are missing changelog labels.', '32', $ansi) . "\n"; + + exit(0); +} + +exit(1); diff --git a/admin/docbot b/admin/docbot deleted file mode 100755 index c4dd8229c9ac..000000000000 --- a/admin/docbot +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -# Rebuild and deploy CodeIgniter4 under-development user guide -# -# This is a quick way to test user guide changes, and if they -# look good, to push them to the gh-pages branch of the -# development repository. -# -# This is not meant for updating the "stable" user guide. - -UPSTREAM=https://github.com/codeigniter4/CodeIgniter4.git - -# Prepare the nested repo clone folder -cd user_guide_src -rm -rf build/* -mkdir build/html - -# Get ready for git -cd build/html -git init -git remote add origin $UPSTREAM -git fetch -git checkout gh-pages -git reset --hard origin/gh-pages -rm -r * - -# Make the new user guide -cd ../.. -make html - -# All done? -if [ $# -lt 1 ]; then - exit 0 -fi - -# Optionally update the remote repo -if [ $1 = "deploy" ]; then - cd build/html - git add . - git commit -S -m "Docbot synching" - git push -f origin gh-pages -fi \ No newline at end of file diff --git a/admin/docbot.md b/admin/docbot.md deleted file mode 100644 index a3577b2e5d36..000000000000 --- a/admin/docbot.md +++ /dev/null @@ -1,39 +0,0 @@ -# docbot - -Builds & deploys user guide. - -The in-progress CI4 user guide, warts & all, is rebuilt in a nested -repository clone (`user_guide_src/build/html`), with the result -optionally pushed to the `gh-pages` branch of the repo. -That would then be publicly visible as the in-progress -version of the [User Guide](https://codeigniter4.github.io/CodeIgniter4/). - -## Requirements - -You must have python & sphinx installed. - -## Audience - -This script is intended for use by framework maintainers, -i.e. someone with commit rights on the CI4 repository. - -This script wraps the conventional user guide building, -i.e. `user_guide_src/make html`, with additional -steps. - -You will be prompted for your github credentials and -GPG-signing key as appropriate. - -## Usage - -Inside a shell prompt, in the project root: - - `admin/docbot [deploy]` - -If "deploy" is not added, the script execution is considered -a trial run, and nothing is pushed to the repo. - -Whether or not deployed, the results are left inside -user_guide_src/build (which is git ignored). - -Generate these and the API docs together with the 'alldocs' script. diff --git a/admin/generate-changelog.php b/admin/generate-changelog.php new file mode 100644 index 000000000000..f6578cb1372f --- /dev/null +++ b/admin/generate-changelog.php @@ -0,0 +1,153 @@ + [--dry-run] + */ +chdir(__DIR__ . '/..'); + +$args = array_slice($argv, 1); +$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--'))); +$params = array_values(array_diff($args, $options)); + +if (count($params) !== 1 || preg_match('/\A\d+\.\d+\.\d+\z/', $params[0]) !== 1) { + echo "Usage: php {$argv[0]} [--dry-run]\n"; + echo "E.g.,: php {$argv[0]} 4.7.5\n"; + + exit(1); +} + +$version = $params[0]; +$dryRun = in_array('--dry-run', $options, true); +$repo = 'codeigniter4/CodeIgniter4'; + +$changelogPath = './CHANGELOG.md'; +$changelog = file_get_contents($changelogPath); + +if (! $dryRun && str_contains($changelog, "## [v{$version}]")) { + echo "CHANGELOG.md already contains an entry for v{$version}.\n"; + + exit(1); +} + +// Fetches the auto-generated release notes. +$command = sprintf( + 'gh api repos/%s/releases/generate-notes -f tag_name=v%s -f target_commitish=develop 2>&1', + $repo, + $version, +); +exec($command, $output, $exitCode); + +if ($exitCode !== 0) { + echo "Failed to fetch the release notes from GitHub:\n"; + echo implode("\n", $output) . "\n"; + + exit(1); +} + +$response = json_decode(implode("\n", $output), true); +$body = $response['body'] ?? ''; + +// Parses the release notes into categories. See ".github/release.yml". +$sections = []; +$current = null; +$previousTag = null; + +foreach (preg_split('/\R/', $body) as $line) { + if (preg_match('/^### (.+)$/', $line, $matches) === 1) { + $current = $matches[1]; + $sections[$current] = []; + + continue; + } + + if (str_starts_with($line, '## ')) { + $current = null; + + continue; + } + + if (preg_match('~^\*\*Full Changelog\*\*: https://github\.com/[^/]+/[^/]+/compare/(.+?)\.\.\.~', $line, $matches) === 1) { + $previousTag = $matches[1]; + + continue; + } + + if ($current !== null && str_starts_with($line, '* ')) { + $sections[$current][] = $line; + } +} + +if ($previousTag === null) { + echo "Could not determine the previous tag from the release notes.\n"; + + exit(1); +} + +// PRs in the catch-all category have none of the changelog labels. They are +// listed for checking only and are not part of the generated entry. +$othersTitle = 'Others (Only for checking. Remove this category)'; +$others = $sections[$othersTitle] ?? []; +unset($sections[$othersTitle]); + +// Extracts the security fixes from the SECURITY section of the detailed +// changelog, if any. These come from security advisories, not from PRs, so +// they are not part of the generated release notes. +$rstPath = "./user_guide_src/source/changelogs/v{$version}.rst"; +$security = ''; + +if (is_file($rstPath)) { + $rst = file_get_contents($rstPath); + + if (preg_match('/^\*+\nSECURITY\n\*+\n(.*?)(?=^\*+$|\z)/msu', $rst, $matches) === 1) { + $security = trim($matches[1], "\n"); + $security = preg_replace('/^- /mu', '* ', $security); + } +} + +$date = date('Y-m-d'); +$entry = "## [v{$version}](https://github.com/{$repo}/tree/v{$version}) ({$date})\n"; +$entry .= "[Full Changelog](https://github.com/{$repo}/compare/{$previousTag}...v{$version})\n"; + +if ($security !== '') { + $entry .= "\n### Security\n\n{$security}\n"; +} + +foreach ($sections as $title => $items) { + if ($items === []) { + continue; + } + + $entry .= "\n### {$title}\n\n"; + $entry .= implode("\n", $items) . "\n"; +} + +if ($others !== []) { + echo "The following PRs have no changelog label and were NOT included.\n"; + echo "If any of them belong in the changelog, label the PR and run this script again:\n"; + echo implode("\n", $others) . "\n\n"; +} + +if ($dryRun) { + echo $entry; + + exit(0); +} + +$updated = preg_replace('/\A# Changelog\n\n/', "# Changelog\n\n{$entry}\n", $changelog, 1, $count); + +if ($count !== 1) { + echo 'Could not find the "# Changelog" header in CHANGELOG.md.' . "\n"; + + exit(1); +} + +file_put_contents($changelogPath, $updated); + +echo "Added the v{$version} entry to CHANGELOG.md.\n"; diff --git a/admin/release b/admin/release deleted file mode 100755 index d14167f0502e..000000000000 --- a/admin/release +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash - -## Build framework release branch & distributables - -# Setup variables -. admin/release-config - -echo -e "${BOLD}${COLOR}CodeIgniter4 release builder${NORMAL}" -echo '----------------------------' - -#--------------------------------------------------- -# Check arguments -echo -e "${BOLD}Checking arguments...${NORMAL}" - -if [ $# -lt 1 ]; then - echo -e "${BOLD}Usage: admin/release version# pre-release-qualifier${NORMAL}" - exit 1 -fi - -#--------------------------------------------------- -# Create the release branch -echo -e "${BOLD}Creating $which $branch to $action ${NORMAL}" - -git checkout develop -git branch -d $branch &>/dev/null # remove the branch if there -git checkout -b $branch -composer update - -#--------------------------------------------------- -# Update version dependencies -echo -e "${BOLD}Updating version dependencies${NORMAL}" - -function check_unique { - count=`grep -c '$1' < $2 | wc -l` - if [ $count -ne 1 ]; then - echo -e "${BOLD}${COLOR}$2 has ${count} occurrences of '$1'${NORMAL}" - exit 1 - fi -} - -# Make sure there is only one line to affect in each file -check_unique "const CI_VERSION" 'system/CodeIgniter.php' -check_unique "release =" 'user_guide_src/source/conf.py' -check_unique "|release|" 'user_guide_src/source/changelogs/index.rst' -check_unique "Release Date.*Not Released" 'user_guide_src/source/changelogs/index.rst' - -# CI_VERSION definition in system/CodeIgniter.php -sed -i "/const CI_VERSION/s/'.*'/'${RELEASE}'/" system/CodeIgniter.php - -# release substitution variable in user_guide_src/source/conf.py -sed -i "/release =/s/'.*'/'${RELEASE}'/" user_guide_src/source/conf.py - -# version & date in user_guide_src/source/index.rst -sed -i "/|release|/s/|.*|/${RELEASE}/" user_guide_src/source/changelogs/index.rst -sed -i "/Release Date/s/Not Released/$(date +'%B %d, %Y')/" user_guide_src/source/changelogs/index.rst -sed -i "/|version|/s/|version|/${RELEASE}/" user_guide_src/source/changelogs/index.rst - -# version & date in user_guide_src/source/next.rst -sed -i "/Release Date/s/Not Released/$(date +'%B %d, %Y')/" user_guide_src/source/changelogs/next.rst -sed -i "/|version|/s/|version|/${RELEASE}/" user_guide_src/source/changelogs/next.rst - -# establish version-specific changelog -sed -i "|changelogs/next|s|changeslog/next|changelogs/v{$RELEASE}|" user_guide_src/source/changelogs/index.rst -mv user_guide_src/source/changelogs/next.rst user_guide_src/source/changelogs/v${RELEASE}.rst -touch user_guide_src/source/changelogs/next.rst -cp admin/next.rst user_guide_src/source/changelogs/next.rst - -#--------------------------------------------------- -# Setup the distribution folders -echo -e "${BOLD}Building distribution folders${NORMAL}" - -function setup_repo { - echo -e "${BOLD}... $1${NORMAL}" - if [ -d dist/$1 ]; then - rm -rf dist/$1 - fi - mkdir dist/$1 - cd dist/$1 - git init - git remote add origin ${CI_ORG}/$1.git - git fetch - git checkout master - git checkout -b $branch - cd $CI_DIR -} - -if [ -d dist ]; then - rm -rf dist/ -fi -mkdir dist - -setup_repo userguide - -#--------------------------------------------------- -# Housekeeping - make sure writable is flushed of test files -# at least, test files that crop up on my system :-/ -rm -f writable/cache/H* -rm -f writable/cache/d* -rm -f writable/cache/s* -rm -f writable/debugbar/debug* -rm -f writable/logs/log* - -#--------------------------------------------------- -# Generate the user guide -echo -e "${BOLD}Generate the user guide${NORMAL}" - -cd user_guide_src - -# make the UG -rm -rf build/* -echo -e "${BOLD}... HTML version${NORMAL}" -make html -touch build/html/.nojekyll -echo -e "${BOLD}... epub version${NORMAL}" -make epub - -cd ${CI_DIR} - -# add changelog preamble -file=user_guide_src/source/changelogs/index.rst -sed -i "4 a Version |version|" $file -sed -i "5 a ====================================================" $file -sed -i "6 G" $file -sed -i "7 a Release Date: Not Released" $file -sed -i "8 G" $file -sed -i "9 a **Next release of CodeIgniter4**" $file -sed -i "10 G" $file -sed -i "11 G" $file -sed -i "12 a :doc:\`See all the changes. \`" $file -sed -i "13 G" $file - -#--------------------------------------------------- -echo -e "${BOLD}Commit the release branch${NORMAL}" -git add . -git commit -S -m "Release ${RELEASE}" - -#--------------------------------------------------- -# Build the distributables - -. admin/release-userguide - -#--------------------------------------------------- -# Done for now -echo -e "${BOLD}Your $branch branch is ready to inspect.${NORMAL}" -echo -e "${BOLD}Follow the directions in workflow.md to continue.${NORMAL}" diff --git a/admin/release-config b/admin/release-config deleted file mode 100644 index 8a969af81c68..000000000000 --- a/admin/release-config +++ /dev/null @@ -1,26 +0,0 @@ -# Variables used for release building - -if [ -z "$CI_ORG" ]; then - - # Initialize variables - CI_ORG=https://github.com/codeigniter4 - CI_DIR=`pwd` - - BOLD='\033[1m' - NORMAL='\033[0m' - COLOR='\033[1;31m' - ERROR='\033[0;31m' - - qualifier= - which=release - - version=$1 - if [ $# -gt 1 ]; then - qualifier="-${2}" - which='pre-release' - fi - - RELEASE=$version$qualifier - branch="release-$RELEASE" - -fi diff --git a/admin/release-deploy b/admin/release-deploy deleted file mode 100755 index fe70284024fc..000000000000 --- a/admin/release-deploy +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -## Push local changes to github - -echo -e "${BOLD}${COLOR}CodeIgniter4 release deployment${NORMAL}" -echo '-------------------------------' - -. admin/release-config - -echo -e "${BOLD}Merge release into develop${NORMAL}" -git checkout develop -git merge $branch -git push origin develop -git push ${CI_ORG}/CodeIgniter4 develop - -echo -e "${BOLD}Merge develop into master${NORMAL}" -git checkout master -git merge develop -git push origin master -git push ${CI_ORG}/CodeIgniter4 master - -echo -e "${BOLD}Pushing to the user guide repository${NORMAL}" -cd ${CI_DIR}/dist/userguide -git push origin master - -cd ${CI_DIR} - -#--------------------------------------------------- -# Phew! -echo -e "${BOLD}Congratulations - we have liftoff${NORMAL}" -echo "Don't forget to announce this release on the forum and on twitter!" diff --git a/admin/release-notes.bb b/admin/release-notes.bb deleted file mode 100755 index 6147a011cde1..000000000000 --- a/admin/release-notes.bb +++ /dev/null @@ -1,43 +0,0 @@ -CAUTION: THIS FILE IS A MIX OF BBCODE & MARKDOWN... NEEDS PROOFING - -CodeIgniter-4.0.0-alpha.1 launches today, after a lengthy build-up :) - -Huge shoutout to Lonnie Ezell for all of his hard work getting the vision -and the core implementation in place! - -This is an early pre-release of 4.0.0. It is not suitable for production! - -There are several possible downloads, that you can see on the -[url=https://github.com/codeigniter4/CodeIgniter4/releases/tag/v4.0.0-alpha.1]release page[/url]) - -- the runnable versions as a -[zip](https://github.com/codeigniter4/CodeIgniter4/releases/download/v4.0.0-alpha.1/CodeIgniter-4.0.0-alpha.1.zip) or a -[tarball](https://github.com/codeigniter4/CodeIgniter4/releases/download/v4.0.0-alpha.1/CodeIgniter-4.0.0-alpha.1.tar.gz)/ -- the developer versions of the framework (with contributor components) as -a [zip](https://github.com/codeigniter4/CodeIgniter4/archive/v4.0.0-alpha.1.zip) or a -[tarball](https://github.com/codeigniter4/CodeIgniter4/archive/v4.0.0-alpha.1.tar.gz)/ -- and finally the [epub](https://github.com/codeigniter4/CodeIgniter4/releases/download/v4.0.0-alpha.1/CodeIgniter-4.0.0-alpha.1.epub) version of the user guide for this release. - -The release has all the major features in place, but there are still gaps -and issues. See ... - -- [Bugs in the 4.0.0-alpha.1](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aopen+is%3Aissue+milestone%3A4.0.0-alpha) -- [Things that we definitely want to see fixed before first proper release](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aopen+is%3Aissue+milestone%3A4.0.0) -- [Features we would like to see in the initial proper release](), if possible, else the next update to it -- [Features that we are consciously deferring to later](https://github.com/codeigniter4/CodeIgniter4/issues?q=is%3Aopen+is%3Aissue+milestone%3A4.1.0) - -What we need now is feedback from the community ... - -- What doesn't work well (raise a post in [url=https://forum.codeigniter.com/forum-27.html]CodeIgniter 4 Development[/url]). -If you have found a bug, then by all means create a new issue on the repo. -- What you think is missing (raise a post in [url=https://forum.codeigniter.com/forum-29.html]CodeIgniter 4 Feature Requests[/url]) -We will soon start the [url=https://forum.codeigniter.com/forum-33.html]candidate new feature[/url] posts, -for those items in the feature requests forum that appear to have traction -- Problems that you are having using the release (raise a post in [url=https://forum.codeigniter.com/forum-30.html]CodeIgniter 4 Support[/url]) -Do NOT create repo issues with support questions - we are using github for bug and work package tracking - -Do NOT post support questions or feature requests in response to this thread - those -will be deleted. We are trying to make the best of the -limited resources that we have! - -Thank you, and ENJOY! \ No newline at end of file diff --git a/admin/release-revert b/admin/release-revert deleted file mode 100755 index 1c8a4eacd504..000000000000 --- a/admin/release-revert +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -## Revert local repos to pre-release state -echo -e "${BOLD}${COLOR}CodeIgniter4 release revert${NORMAL}" -echo '---------------------------' - -if [ $# -lt 2 ]; then - echo "You forgot the magic word" - exit 1 -fi -if [ $1 != 'please' ]; then - echo "What do you say?" - exit 1 -fi - -. admin/release-config - -echo -e "${BOLD}Reverting the main repository${NORMAL}" -git checkout master -git pull -f ${CI_ORG}/CodeIgniter4 master -git checkout develop -git pull -f${CI_ORG}/CodeIgniter4 develop - -#--------------------------------------------------- -# Phew! -echo -e "${BOLD}Congratulations - we have aborted liftoff${NORMAL}" diff --git a/admin/release-userguide b/admin/release-userguide deleted file mode 100755 index 6a8beb7bcbaf..000000000000 --- a/admin/release-userguide +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash - -## Build user guide distributable - -# Setup variables -. admin/release-config -TARGET=dist/userguide -cd $TARGET -git checkout $branch - -#--------------------------------------------------- -echo -e "${BOLD}Build the user guide distributable${NORMAL}" - -cp -rf ${CI_DIR}/user_guide_src/build/html/* docs -cp -rf ${CI_DIR}/user_guide_src/build/epub/CodeIgniter4.epub ./CodeIgniter${RELEASE}.epub - -#--------------------------------------------------- -# And finally, get ready for merging -echo -e "${BOLD}Assemble the pieces...${NORMAL}" -git add . -git commit -S -m "Release ${RELEASE}" -git checkout master -git merge $branch - -cd $CI_DIR - -#--------------------------------------------------- -# Done for now -echo -e "${BOLD}Distributable user guide ready..${NORMAL}" diff --git a/admin/starter/tests/unit/HealthTest.php b/admin/starter/tests/unit/HealthTest.php index 1df24df823a7..bae45560b788 100644 --- a/admin/starter/tests/unit/HealthTest.php +++ b/admin/starter/tests/unit/HealthTest.php @@ -11,7 +11,7 @@ final class HealthTest extends CIUnitTestCase { public function testIsDefinedAppPath(): void { - $this->assertTrue(defined('APPPATH')); + $this->assertDirectoryExists(APPPATH); } public function testBaseUrlHasBeenSet(): void diff --git a/admin/sync-release-branches.php b/admin/sync-release-branches.php new file mode 100644 index 000000000000..4f82a424bc65 --- /dev/null +++ b/admin/sync-release-branches.php @@ -0,0 +1,96 @@ + [--push] + */ +function run_command(string $command): void +{ + echo sprintf("$ %s\n", $command); + system($command, $exitCode); + + if ($exitCode !== 0) { + exit($exitCode); + } +} + +function merge_branch(string $ref): void +{ + echo sprintf("$ git merge %s\n", $ref); + system(sprintf('git merge %s', escapeshellarg($ref)), $exitCode); + + if ($exitCode !== 0) { + echo sprintf("\nMerging %s failed. Resolve the conflicts and run \"git merge --continue\",\n", $ref); + echo "then push with \"git push upstream HEAD\".\n"; + + exit(1); + } +} + +chdir(__DIR__ . '/..'); + +$args = array_slice($argv, 1); +$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--'))); +$params = array_values(array_diff($args, $options)); + +$isBranch = static fn (string $name): bool => preg_match('/\A(?:develop|master|\d+\.\d+)\z/', $name) === 1; + +if ( + count($params) !== 2 + || ! $isBranch($params[0]) + || ! $isBranch($params[1]) + || $params[0] === $params[1] + || array_diff($options, ['--push']) !== [] +) { + echo sprintf("Usage: php %s [--push]\n", $argv[0]); + echo sprintf("E.g.,: php %s 4.8 develop --push # merges develop into 4.8\n", $argv[0]); + echo sprintf(" php %s develop master --push # merges master into develop\n", $argv[0]); + echo "Checks out , merges upstream/ and then upstream/ into it.\n"; + echo "Without --push, the result is left unpushed for review.\n"; + + exit(1); +} + +[$target, $source] = $params; +$push = in_array('--push', $options, true); + +exec('git remote get-url upstream 2>&1', $remoteOutput, $exitCode); + +if ($exitCode !== 0) { + echo "The \"upstream\" remote is not configured.\n"; + + exit(1); +} + +exec('git status --porcelain 2>&1', $statusOutput, $exitCode); + +if ($exitCode !== 0 || $statusOutput !== []) { + echo "The working tree is not clean. Commit or stash your changes first.\n"; + + exit(1); +} + +run_command('git fetch upstream'); + +exec(sprintf('git rev-parse --verify --quiet refs/heads/%s 2>&1', escapeshellarg($target)), $verifyOutput, $exitCode); + +if ($exitCode === 0) { + run_command(sprintf('git switch %s', escapeshellarg($target))); +} else { + run_command(sprintf('git switch -c %s upstream/%s', escapeshellarg($target), escapeshellarg($target))); +} + +merge_branch("upstream/{$target}"); +merge_branch("upstream/{$source}"); + +if ($push) { + run_command('git push upstream HEAD'); + + echo sprintf("Merged upstream/%s into %s and pushed to upstream.\n", $source, $target); +} else { + echo sprintf("Merged upstream/%s into %s. Review the result, then run \"git push upstream HEAD\".\n", $source, $target); +} diff --git a/admin/update-upgrade-guide.php b/admin/update-upgrade-guide.php new file mode 100644 index 000000000000..c80f0a75b635 --- /dev/null +++ b/admin/update-upgrade-guide.php @@ -0,0 +1,197 @@ + [--dry-run] + */ +function previous_release_tag(string $version): ?string +{ + // Not `git describe`: the base must be the highest stable tag below the + // given version regardless of the checked out branch, and prerelease + // tags (e.g. v4.0.0-rc.4) must be excluded. + exec("git tag -l 'v*' 2>&1", $tags, $exitCode); + + if ($exitCode !== 0) { + return null; + } + + $tags = array_filter( + $tags, + static fn (string $tag): bool => preg_match('/\Av\d+\.\d+\.\d+\z/', $tag) === 1 + && version_compare($tag, "v{$version}", '<'), + ); + + if ($tags === []) { + return null; + } + + usort($tags, version_compare(...)); + + return end($tags); +} + +/** + * @param list $items + */ +function bullets(array $items): string +{ + return implode("\n", array_map(static fn (string $item): string => "- {$item}", $items)); +} + +chdir(__DIR__ . '/..'); + +$args = array_slice($argv, 1); +$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--'))); +$params = array_values(array_diff($args, $options)); + +if (count($params) !== 1 || preg_match('/\A\d+\.\d+\.\d+\z/', $params[0]) !== 1) { + echo sprintf("Usage: php %s [--dry-run]\n", $argv[0]); + echo sprintf("E.g.,: php %s 4.7.5\n", $argv[0]); + + exit(1); +} + +$version = $params[0]; +$dryRun = in_array('--dry-run', $options, true); + +$upgradePath = sprintf('./user_guide_src/source/installation/upgrade_%s.rst', str_replace('.', '', $version)); + +if (! is_file($upgradePath)) { + echo sprintf("%s is not found. Run \"php admin/create-new-changelog.php\" first.\n", $upgradePath); + + exit(1); +} + +$baseTag = previous_release_tag($version); + +if ($baseTag === null) { + echo "Could not determine the previous release tag. Are the tags fetched?\n"; + + exit(1); +} + +echo sprintf("Checking the project space files changed since %s.\n", $baseTag); + +// Paths that are not part of the project space. Keep in sync with the +// distribution repos: `tests/` is not used there, see "admin/starter/tests". +$excludes = [ + ':!.github/', ':!admin/', ':!changelogs/', ':!contributing/', + ':!system/', ':!tests/', ':!user_guide_src/', ':!utils/', + ':!*.json', ':!*.xml', ':!*.dist', ':!rector.php', ':!structarmed.php', + ':!phpstan*', ':!psalm*', ':!.php-cs-fixer.*', ':!LICENSE', ':!CHANGELOG.md', +]; + +$command = sprintf( + 'git diff --name-status %s -- . %s 2>&1', + escapeshellarg($baseTag), + implode(' ', array_map(escapeshellarg(...), $excludes)), +); +exec($command, $diffOutput, $exitCode); + +if ($exitCode !== 0) { + echo "Failed to diff against the latest release tag:\n"; + echo implode("\n", $diffOutput) . "\n"; + + exit(1); +} + +$allChanges = []; +$configChanged = []; +$configNew = []; + +foreach ($diffOutput as $line) { + $fields = preg_split('/\t/', $line); + + if ($fields === false || count($fields) < 2) { + continue; + } + + $status = $fields[0][0]; + $path = end($fields); + + $allChanges[] = $status === 'D' ? "{$path} (deleted)" : $path; + + if (! str_starts_with($path, 'app/Config/')) { + continue; + } + + if ($status === 'A') { + $configNew[] = $path; + } elseif ($status !== 'D') { + $configChanged[] = $path; + } +} + +sort($allChanges); +sort($configChanged); +sort($configNew); + +// Builds the "Config" section contents. +$configContent = bullets($configChanged); + +if ($configNew !== []) { + $configContent .= ($configContent === '' ? '' : "\n\n") . "These files are new in this release:\n\n" . bullets($configNew); +} + +if ($configContent === '') { + $configContent = '- No config files were changed in this release.'; +} + +// Builds the "All Changes" section contents. +$allChangesContent = $allChanges === [] + ? '- No project files were changed in this release.' + : bullets($allChanges); + +if ($dryRun) { + echo "\nConfig\n------\n\n{$configContent}\n"; + echo "\nAll Changes\n===========\n\n{$allChangesContent}\n"; + + exit(0); +} + +$rst = file_get_contents($upgradePath); + +// Fills the "Config" section, only if it still has the placeholder. Entries +// may have been added ahead of time for changes that need discussion, and +// those must not be overwritten. +if (preg_match('/^Config\n------\n\n(.*?)(?=^[^\s-][^\n]*\n(?:-+|=+)$|\z)/msu', $rst, $matches) !== 1) { + echo "Could not find the \"Config\" section in {$upgradePath}. Update it manually.\n"; +} elseif (trim($matches[1]) === '- @TODO') { + $rst = str_replace("Config\n------\n\n{$matches[1]}", "Config\n------\n\n{$configContent}\n\n", $rst); + + echo "The \"Config\" section was updated. Add notes to each entry as needed.\n"; +} else { + $mentioned = []; + + if (preg_match_all('~app/Config/[\w./-]+~u', $matches[1], $found) > 0) { + $mentioned = $found[0]; + } + + $missing = array_diff([...$configChanged, ...$configNew], $mentioned); + + if ($missing === []) { + echo "The \"Config\" section already has content and mentions all changed config files. It was not modified.\n"; + } else { + echo "The \"Config\" section already has content, so it was not modified.\n"; + echo "Merge these missing entries manually:\n" . bullets(array_values($missing)) . "\n"; + } +} + +// Fills the "All Changes" section, replacing any previous list. +$pattern = '/^(All Changes\n===========\n\n.*?:\n\n)(.*?)(?=^\*+$|\z)/msu'; +$updated = preg_replace($pattern, "\$1{$allChangesContent}\n", $rst, 1, $count); + +if ($count !== 1) { + echo "Could not find the \"All Changes\" section in {$upgradePath}. Update it manually.\n"; + + exit(1); +} + +file_put_contents($upgradePath, $updated); + +echo sprintf("The \"All Changes\" section of %s was updated.\n", $upgradePath); +echo "Remember to remove the section titles that have no items.\n"; diff --git a/admin/workflow.md b/admin/workflow.md deleted file mode 100644 index 85e38e018ec2..000000000000 --- a/admin/workflow.md +++ /dev/null @@ -1,92 +0,0 @@ -# Workflow - -The main repo has two branches of interest: "master" (stable) and "develop" (in progress). -There might be other feature branches, but they are not relevant to this process. - -Once "develop" is ready for a new release, the general workflow is to - -release prep (`admin/release `)... -- create a "release" branch from develop -- update version dependencies or constants -- generate version(s) of the user guide -- move or ignore stuff, distinguishing release from development -- test that all is as it should be -- merge the release branch into "master" & "develop" - -After these have been vetted ... -- push the release(s) to github (`admin/release-deploy `) -- **manually** create the release & tag it on GitHub, based on master - Include any supplementary binaries as part of releases. -- Confirm the GitHub release action successfully deploys `appstarter` and `framework` -- **manually** post a sticky announcement thread on the forum -- **manually** tweet the release announcement - -## Assumptions - -You (a maintainer) have forked the main CodeIgniter4 repo, -and the git alias `origin`, in your local clone, refers to your fork. -The `config` script defines an additional alias, `CI_ORG`, which refers to the -CodeIgniter 4 organization on github. -This separation keeps the release branch isolated for any testing you want to do. - -The `develop` branch of the main repo should be "clean", and ready for -a release. This means that the changelog and upgrading instructions -have been suitably edited. - -This script is not intended to deal with hotfixes, i.e. PRs against -`master` that need to also be merged into `develop`, probably -as part of a bug fix minor release. - -## GitHub Action - -There is an action defined to run on any release publish event: -**.github/workflows/deploy.yml**. This will cascade any release made from -the main repo to the distribution repos, `appstarter` and `framework`. In order -for the action to authenticate you must create a Personal Access Token and add it -as a repo secret `ACCESS_TOKEN`. It is recommended that the PAT be to a secure bot -account with organization access that is dedicated for this purpose. - -If for some reason a release needs to be made that should not cascade the easiest -solution is to disable repo actions temporarily. - -## Usage - -Inside a shell prompt, in the project root: - - `admin/release version [qualifier]` - -Nothing is pushed to the repo at this point. You have a chance to vet -the repository changes. - -The "version" should follow semantic versioning, e.g. `4.0.6`, and the -version number should be higher than the current released one. - -The "qualifier" argument is a suffix to add to the version -for a pre-release, e.g. `beta.2` or `rc.41`. - -Examples: -- `admin/release 4.0.0 alpha.1` would prepare the "4.0.0-alpha.1" pre-release PR -- `admin/release 4.0.0` would prepare the "4.0.0" release PR -- `admin/release peanut butter banana` would complain and tell you to read these directions - -Once you have vetted the `dist` folder inside your local repo, you -can merge & push everything with - - `admin/release-deploy please` - -On github.com, create an appropriate release (or pre-release), -with any optional files from the root of `dist`. - -## Release notes - -On launch of a new release, a release notes post should be made in the -announcements subforum. The planned text for it (so it can be previewed -by admins) is in `admin/release_notes.bb`. - -## Audience - -These scripts are intended for use by framework maintainers, -i.e. someone with commit rights on the CI4 repositories. - -You will be prompted for your github credentials and -GPG-signing key as appropriate. diff --git a/app/Config/View.php b/app/Config/View.php index b52d980dc39f..427a22e56558 100644 --- a/app/Config/View.php +++ b/app/Config/View.php @@ -30,7 +30,7 @@ class View extends BaseView * { title|esc(js) } * { created_on|date(Y-m-d)|esc(attr) } * - * @var array + * @var array */ public $filters = []; @@ -43,6 +43,14 @@ class View extends BaseView */ public $plugins = []; + /** + * When true, Parser conditionals may only contain variables, literals, + * arithmetic, comparison and logical operators, and parentheses. Enable + * this when less-trusted users can edit Parser templates. Can be overridden per + * call with the `restrictConditionals` render option. + */ + public bool $restrictParserConditionals = false; + /** * View Decorators are class methods that will be run in sequence to * have a chance to alter the generated output just prior to caching diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index ab45a7710be9..6f86f9254ed1 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -3,6 +3,8 @@ namespace App\Controllers; use CodeIgniter\Controller; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; @@ -28,6 +30,8 @@ abstract class BaseController extends Controller // protected $session; /** + * @param CLIRequest|IncomingRequest $request + * * @return void */ public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger) diff --git a/composer.json b/composer.json index 8e0990839d9f..9aa9641b8e6d 100644 --- a/composer.json +++ b/composer.json @@ -17,19 +17,19 @@ "psr/log": "^3.0" }, "require-dev": { - "boundwize/structarmed": "0.14.10", + "boundwize/structarmed": "^0.18", "codeigniter/phpstan-codeigniter": "^2.1", "fakerphp/faker": "^1.24", "kint-php/kint": "^6.1", "mikey179/vfsstream": "^1.6.12", "nexusphp/tachycardia": "^2.0", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.2.1", + "phpstan/phpstan": "^2.2.15", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpcov": "^9.0.2 || ^10.0", "phpunit/phpunit": "^10.5.16 || ^11.2", "predis/predis": "^3.0", - "rector/rector": "2.5.4", + "rector/rector": "2.7.0", "shipmonk/phpstan-baseline-per-identifier": "^2.0" }, "replace": { diff --git a/contributing/README.md b/contributing/README.md index 40796df376b3..981b69169aca 100644 --- a/contributing/README.md +++ b/contributing/README.md @@ -14,3 +14,4 @@ if you want to take the lead on one of them. - [Contributor Covenant Code of Conduct](../CODE_OF_CONDUCT.md) - [Reporting a Bug](./bug_report.md) - [Sending a Pull Request](./pull_request.md) +- [Configuring Upstream and Rebasing](./workflow.md#configuring-upstream-and-rebasing) diff --git a/contributing/pull_request.md b/contributing/pull_request.md index dbea94ccbd6f..844d62046cfa 100644 --- a/contributing/pull_request.md +++ b/contributing/pull_request.md @@ -192,11 +192,16 @@ specific examples to pay attention to: Your PRs need to be mergeable and GPG-signed before they will be considered. -We suggest that you synchronize your repository's `develop` branch with -that in the main repository, and then your feature branch and your -develop branch, before submitting a PR. You will need to resolve any -merge conflicts introduced by changes incorporated since you started -working on your contribution. +Before submitting a PR, rebase your contribution branch onto the latest +appropriate branch from the main repository. Bug fixes should be rebased onto +`upstream/develop`, while features and enhancements should be rebased onto the +applicable `upstream/4.x` branch. See +[Configuring Upstream and Rebasing](./workflow.md#configuring-upstream-and-rebasing) +for the commands and conflict-resolution steps. + +Do not merge the target branch into your contribution branch. This may add merge +commits that make the PR and project history harder to follow. Rebasing keeps +the commit history linear and easier to review. ### Branching @@ -328,10 +333,11 @@ GitHub means "making a copy of that repo to your account" and "cloning" means tests yet, please create one. - `> vendor/bin/phpunit tests/system/path/to/file/you/modified` - Make sure the tests pass to have a higher chance of merging. -11. [Push](https://docs.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) your contribution branch to your fork. +11. [Rebase your branch](./workflow.md#configuring-upstream-and-rebasing) onto the appropriate upstream branch. +12. [Push](https://docs.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) your contribution branch to your fork. - `> git push origin ` -12. Send a [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork). -13. Label your pull request with the appropriate label if you can. +13. Send a [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork). +14. Label your pull request with the appropriate label if you can. See [Contribution workflow](./workflow.md) for Git workflow details. @@ -340,8 +346,8 @@ the team will respond. If your change fails to meet the guidelines, it will be rejected or feedback will be provided to help you improve it. Once the maintainer handling your pull request is satisfied with it, they will -approve the pull request and merge it into the "develop" branch. Your patch will -now be part of the next release! +approve and merge the pull request into the appropriate target branch. Your +patch will now be part of the next release! ## Translating System Messages diff --git a/contributing/workflow.md b/contributing/workflow.md index a294cc9e485c..2bb3e2d7e127 100644 --- a/contributing/workflow.md +++ b/contributing/workflow.md @@ -67,39 +67,72 @@ Clone your repository, leaving a local folder for you to work with: > git clone ORIGIN_URL ``` -## Syncing Your Repository +## Configuring Upstream and Rebasing -Within your local repository, Git will have created an alias, -**origin**, for the GitHub repository it is bound to. You want to create -an alias for the shared repository as well, so that you can "synch" the -two, making sure that your repository includes any other contributions -that have been merged by us into the shared repo: +Git names your fork `origin` when you clone it. Add the main CodeIgniter4 +repository as `upstream` so that you can fetch changes made by other +contributors. You only need to add this remote once: ```console -> git remote add upstream UPSTREAM_URL +> git remote add upstream https://github.com/codeigniter4/CodeIgniter4.git +> git remote -v ``` -Then synchronizing is done by pulling from us and pushing to you. This -is normally done locally, so that you can resolve any merge conflicts. -For instance, to synchronize **develop** branches: +If `upstream` already exists but uses the wrong URL, update it instead: + +```console +> git remote set-url upstream https://github.com/codeigniter4/CodeIgniter4.git +``` + +Fetch the latest upstream branches before rebasing: ```console > git fetch upstream -> git switch develop -> git merge upstream/develop -> git push origin develop ``` -You might get merge conflicts when you merge. It is your -responsibility to resolve those locally, so that you can continue -collaborating with the shared repository. Basically, the shared -repository is updated in the order that contributions are merged into -it, not in the order that they might have been submitted. If two PRs -update the same piece of code, then the first one to be merged will take -precedence, even if it causes problems for other contributions. +Rebase bug-fix branches onto `upstream/develop`: + +```console +> git switch your-branch +> git rebase upstream/develop +``` + +Features and enhancements target the next minor-version branch. Replace `4.x` +with the branch used by your pull request: + +```console +> git switch your-branch +> git rebase upstream/4.x +``` + +> [!IMPORTANT] +> Do not merge the upstream target branch into your contribution branch. A merge +> may add merge commits that make the pull request and project history harder to +> follow. Rebasing keeps the commit history linear and easier to review. + +Make sure local work is committed or stashed before rebasing. If Git reports a +conflict, edit the affected files, mark them as resolved, and continue: + +```console +> git add path/to/resolved-file +> git rebase --continue +``` + +Repeat these commands until the rebase is complete. To cancel the rebase and +return the branch to its previous state, run: + +```console +> git rebase --abort +``` + +If the branch was already pushed to your fork, update it safely after rebasing: + +```console +> git push --force-with-lease origin your-branch +``` -It is a good idea to synchronize repositories when the shared one -changes. +Use `--force-with-lease` rather than `--force` so that Git does not overwrite +remote changes that you have not fetched. ## Branching Revisited @@ -250,25 +283,9 @@ are working in. At some point, you will decide that your feature branch is complete, or that it could benefit from a review by fellow developers. -> [!NOTE] -> Remember to sync your local repo with the shared one before pushing! -It is a lot easier to resolve conflicts at this stage. - -Synchronize your repository: - -```console -> git fetch upstream -> git switch develop -> git merge upstream/develop -> git push origin develop -``` - -Bring your feature branch up to date: - -```console -> git switch fix/issue-123 -> git rebase upstream/develop -``` +Before pushing, follow +[Configuring Upstream and Rebasing](#configuring-upstream-and-rebasing) to bring +your contribution branch up to date and resolve any conflicts. And finally push your local branch to your GitHub repository: @@ -325,34 +342,15 @@ If you are asked for changes in the review, commit the fix in your branch and push it to GitHub again. If the __"develop"__ or next minor version branch, e.g. __"4.6"__, progresses -and conflicts arise that prevent merging, or if you are asked to *rebase*, -do the following: - -Synchronize your repository: - -```console -> git fetch upstream -> git switch develop -> git merge upstream/develop -> git push origin develop -``` - -(Optional) Create a new branch as a backup, just in case: +and conflicts arise that prevent merging, or if you are asked to *rebase*, you +may first create a backup branch: ```console > git branch fix/problem123.bk fix/problem123 ``` -Bring your feature branch up to date: - -```console -> git switch fix/problem123 -> git rebase upstream/develop -``` - -You might get conflicts when you rebase. It is your -responsibility to resolve those locally, so that you can continue -collaborating with the shared repository. +Then follow +[Configuring Upstream and Rebasing](#configuring-upstream-and-rebasing). Occasionally, the Composer packages for development may be updated. Run the following command to use the latest packages: @@ -380,13 +378,10 @@ Copy the IDs of any commits you made that you want to keep: > git log ``` -Update your __"4.6"__ branch: +Fetch the latest upstream branches: ```console > git fetch upstream -> git switch 4.6 -> git merge upstream/4.6 -> git push origin 4.6 ``` (Optional) Create a new branch as a backup, just in case: @@ -398,7 +393,7 @@ Update your __"4.6"__ branch: Rebase your PR branch from `develop` onto __"4.6"__: ```console -> git rebase --onto 4.6 develop feat-abc +> git rebase --onto upstream/4.6 develop feat-abc ``` Force push. diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index 4ec68a4bfaea..0f25b06d5f66 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -10,7 +10,7 @@ api/build/ api/cache/ - + system diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 2e05af3201a5..7d6f2d81a709 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -2,7 +2,9 @@ includes: - utils/phpstan-baseline/loader.neon parameters: - phpVersion: 80200 + phpVersion: + min: 80200 + max: 80599 tmpDir: build/phpstan level: 6 bootstrapFiles: @@ -18,7 +20,6 @@ parameters: - app/Views/errors/html/* - system/Commands/Generators/Views/* - system/Debug/Toolbar/Views/toolbar.tpl.php - - system/Images/Handlers/GDHandler.php - system/Test/Mock/MockCommon.php - system/ThirdParty/* - system/Validation/Views/single.php diff --git a/psalm.xml b/psalm.xml index d6033313a621..6b9ccb30f513 100644 --- a/psalm.xml +++ b/psalm.xml @@ -30,6 +30,19 @@ + + + + + + + + + + + + + diff --git a/rector.php b/rector.php index c3a1b22ad716..2698aec9f098 100644 --- a/rector.php +++ b/rector.php @@ -13,35 +13,25 @@ use Rector\Caching\ValueObject\Storage\FileCacheStorage; use Rector\CodeQuality\Rector\BooleanNot\NegatedAndsToPositiveOrsRector; -use Rector\CodeQuality\Rector\Empty_\SimplifyEmptyCheckOnEmptyArrayRector; +use Rector\CodeQuality\Rector\ClassMethod\LocallyCalledStaticMethodToNonStaticRector; use Rector\CodeQuality\Rector\FuncCall\CompactToVariablesRector; -use Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector; use Rector\CodeQuality\Rector\Isset_\IssetOnPropertyObjectToPropertyExistsRector; -use Rector\CodeQuality\Rector\Ternary\TernaryEmptyArrayArrayDimFetchToCoalesceRector; use Rector\CodingStyle\Rector\ClassMethod\FuncGetArgsToVariadicParamRector; use Rector\CodingStyle\Rector\ClassMethod\MakeInheritedMethodVisibilitySameAsParentRector; -use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\CodingStyle\Rector\FuncCall\VersionCompareFuncCallToConstantRector; use Rector\Config\RectorConfig; -use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodRector; use Rector\DeadCode\Rector\MethodCall\RemoveNullArgOnNullDefaultParamRector; -use Rector\EarlyReturn\Rector\Foreach_\ChangeNestedForeachIfsToEarlyContinueRector; -use Rector\EarlyReturn\Rector\If_\ChangeIfElseValueAssignToEarlyReturnRector; -use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector; -use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector; +use Rector\DeadCode\Rector\Property\RemoveDefaultValueFromAssignedPropertyRector; use Rector\Php70\Rector\FuncCall\RandomFunctionRector; use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector; use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; -use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector; -use Rector\PHPUnit\CodeQuality\Rector\FuncCall\AssertFuncCallToPHPUnitAssertRector; use Rector\PHPUnit\CodeQuality\Rector\StmtsAwareInterface\DeclareStrictTypesTestsRector; use Rector\Privatization\Rector\Class_\FinalizeTestCaseClassRector; use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector; use Rector\Renaming\Rector\ConstFetch\RenameConstantRector; -use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\AddMethodCallBasedStrictParamTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector; @@ -50,13 +40,14 @@ use Rector\TypeDeclaration\Rector\Function_\AddFunctionVoidReturnTypeWhereNoReturnRector; use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; +use Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector; use Utils\Rector\PassStrictParameterToFunctionParameterRector; use Utils\Rector\RemoveErrorSuppressInTryCatchStmtsRector; use Utils\Rector\UnderscoreToCamelCaseVariableNameRector; return RectorConfig::configure() ->withPhpSets(php82: true) - ->withPreparedSets(deadCode: true, instanceOf: true, phpunitCodeQuality: true) + ->withPreparedSets(deadCode: true, codeQuality: true, instanceOf: true, phpunitCodeQuality: true) ->withComposerBased(phpunit: true) ->withParallel(120, 8, 10) ->withCache( @@ -77,13 +68,14 @@ __DIR__ . '/phpstan-bootstrap.php', ]) ->withPHPStanConfigs([ - __DIR__ . '/phpstan.dist.neon', __DIR__ . '/vendor/codeigniter/phpstan-codeigniter/extension.neon', __DIR__ . '/vendor/phpstan/phpstan-strict-rules/rules.neon', __DIR__ . '/vendor/shipmonk/phpstan-baseline-per-identifier/extension.neon', + __DIR__ . '/phpstan.dist.neon', ]) // is there a file you need to skip? ->withSkip([ + __DIR__ . '/structarmed-baseline.php', __DIR__ . '/system/Debug/Toolbar/Views/toolbar.tpl.php', __DIR__ . '/system/ThirdParty', __DIR__ . '/tests/system/Config/fixtures', @@ -103,8 +95,12 @@ __DIR__ . '/system/HTTP/Response.php', ], + // Keep property defaults for backward compatibility. + RemoveDefaultValueFromAssignedPropertyRector::class, + // Exclude test file because `is_cli()` is mocked and Rector might remove needed parameters. RemoveExtraParametersRector::class => [ + __DIR__ . '/tests/system/CLI/CLITest.php', __DIR__ . '/tests/system/Debug/ToolbarTest.php', ], @@ -149,19 +145,11 @@ __DIR__ . '/system/HTTP/SiteURI.php', ], - // Unnecessary (string) is inserted - NullToStrictStringFuncCallArgRector::class, - CompactToVariablesRector::class, // 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, @@ -178,26 +166,18 @@ // to be applied in separate PRs to ease review NegatedAndsToPositiveOrsRector::class, - RemoveDuplicatedReturnSelfDocblockRector::class, + SafeDeclareStrictTypesRector::class, + LocallyCalledStaticMethodToNonStaticRector::class, ]) // auto import fully qualified class names ->withImportNames() ->withRules([ DeclareStrictTypesRector::class, UnderscoreToCamelCaseVariableNameRector::class, - SimplifyUselessVariableRector::class, - RemoveAlwaysElseRector::class, PassStrictParameterToFunctionParameterRector::class, - CountArrayToEmptyArrayComparisonRector::class, - ChangeNestedForeachIfsToEarlyContinueRector::class, - ChangeIfElseValueAssignToEarlyReturnRector::class, - PreparedValueToEarlyReturnRector::class, RemoveErrorSuppressInTryCatchStmtsRector::class, FuncGetArgsToVariadicParamRector::class, MakeInheritedMethodVisibilitySameAsParentRector::class, - SimplifyEmptyCheckOnEmptyArrayRector::class, - TernaryEmptyArrayArrayDimFetchToCoalesceRector::class, - DisallowedEmptyRuleFixerRector::class, PrivatizeFinalClassPropertyRector::class, VersionCompareFuncCallToConstantRector::class, AddClosureVoidReturnTypeWhereNoReturnRector::class, @@ -210,5 +190,4 @@ ->withConfiguredRule(RenameConstantRector::class, [ 'FILTER_DEFAULT' => 'FILTER_UNSAFE_RAW', ]) - ->withCodeQualityLevel(61) ->reportUnusedSkips(); diff --git a/structarmed-baseline.php b/structarmed-baseline.php new file mode 100644 index 000000000000..eede90ce7048 --- /dev/null +++ b/structarmed-baseline.php @@ -0,0 +1,20 @@ + 'base_classes.must_be_abstract', + 'message' => 'Extended class [CodeIgniter\Config\BaseService] must be declared abstract or instantiated', + 'file' => 'system/Config/BaseService.php', + 'class' => 'CodeIgniter\Config\BaseService', + 'layer' => 'Source', + ], + [ + 'rule' => 'base_classes.must_be_abstract', + 'message' => 'Extended class [CodeIgniter\Debug\Toolbar\Collectors\BaseCollector] must be declared abstract or instantiated', + 'file' => 'system/Debug/Toolbar/Collectors/BaseCollector.php', + 'class' => 'CodeIgniter\Debug\Toolbar\Collectors\BaseCollector', + 'layer' => 'Source', + ], +]; diff --git a/structarmed.php b/structarmed.php index e1027f578ce2..990f039601bc 100644 --- a/structarmed.php +++ b/structarmed.php @@ -11,33 +11,38 @@ * the LICENSE file that was distributed with this source code. */ +use Boundwize\StructArmed\Architecture; +use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset; +use Boundwize\StructArmed\Preset\Presets\Psr4Preset; +use Boundwize\StructArmed\Rule\Rules\Class_\ExtendedClassMustBeAbstractOrInstantiatedRule; +use Boundwize\StructArmed\Rule\Rules\Function_\MustHaveReturnTypeFunctionRule; use CodeIgniter\Cache\ResponseCache; +use CodeIgniter\Database\BaseResult; +use CodeIgniter\DataCaster\DataCaster; +use CodeIgniter\DataConverter\DataConverter; +use CodeIgniter\Entity\Cast\CastInterface; +use CodeIgniter\Entity\Cast\URICast; +use CodeIgniter\Entity\Entity; +use CodeIgniter\Entity\Exceptions\CastException; use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\DownloadResponse; use CodeIgniter\HTTP\Header; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; -use CodeIgniter\DataCaster\DataCaster; -use CodeIgniter\Entity\Cast\CastInterface; -use CodeIgniter\Entity\Exceptions\CastException; -use CodeIgniter\DataConverter\DataConverter; -use CodeIgniter\Entity\Entity; -use CodeIgniter\Entity\Cast\URICast; +use CodeIgniter\HTTP\ResponseTrait; +use CodeIgniter\HTTP\SSEResponse; +use CodeIgniter\HTTP\StreamResponse; use CodeIgniter\HTTP\URI; use CodeIgniter\Log\Handlers\ChromeLoggerHandler; -use CodeIgniter\Security\CheckPhpIni; -use CodeIgniter\View\Table; -use CodeIgniter\Database\BaseResult; -use CodeIgniter\View\Plugins; -use CodeIgniter\HTTP\ResponseTrait; use CodeIgniter\Pager\PagerInterface; -use CodeIgniter\HTTP\Response; -use CodeIgniter\HTTP\RedirectResponse; -use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\Security\CheckPhpIni; use CodeIgniter\Validation\Validation; +use CodeIgniter\View\Plugins; use CodeIgniter\View\RendererInterface; -use Boundwize\StructArmed\Architecture; -use Boundwize\StructArmed\Preset\Preset; -use Boundwize\StructArmed\Preset\Presets\Psr4Preset; +use CodeIgniter\View\Table; return Architecture::define() ->skip([ @@ -45,9 +50,19 @@ __DIR__ . '/tests/system/Config/fixtures', ], __DIR__ . '/system/ThirdParty', + CodeQualityPreset::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC => [ + __DIR__ . '/system/View/Cells/Cell.php', + ], ]) + ->baseline(__DIR__ . '/structarmed-baseline.php') ->cacheDirectory(is_dir('/tmp') ? '/tmp/structarmed' : null) - ->withPreset(Preset::PSR4()) + ->withPresets(Preset::PSR4(), Preset::CODEQUALITY()) + + ->layer('Helpers', __DIR__ . '/system/Helpers') + ->rule('helpers.functions_must_have_return_type', new MustHaveReturnTypeFunctionRule('Helpers')) + + ->rule('base_classes.must_be_abstract', new ExtendedClassMustBeAbstractOrInstantiatedRule('Source', '/^CodeIgniter\\\\.*Base.*$/')) + // Resolve CodeIgniter layers from class names because several layers share directories. ->layerPattern('API', '/^CodeIgniter\\\\API\\\\.*$/') ->layerPattern('Cache', '/^CodeIgniter\\\\Cache\\\\.*$/') @@ -64,11 +79,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\\\\.*$/') @@ -88,26 +105,28 @@ 'Controller' => ['HTTP', 'Validation'], 'Cookie' => ['I18n'], 'Database' => ['Entity', 'Events', 'I18n'], - 'DataCaster' => ['I18n', 'URI', 'Database'], + 'DataCaster' => ['I18n', 'URI', 'Database', 'Encryption'], 'DataConverter' => ['DataCaster'], 'Email' => ['I18n', 'Events'], 'Entity' => ['DataCaster', 'I18n'], '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'], // +API = API + its allowed layers; +Controller = Controller + its allowed layers - 'RESTful' => ['+API', '+Controller'], - 'Router' => ['HTTP', 'I18n'], - 'Security' => ['Cookie', 'HTTP', 'I18n', 'Session'], - 'Session' => ['Cookie', 'Database', 'HTTP', 'I18n'], - 'Throttle' => ['Cache', 'I18n'], - 'Validation' => ['Database', 'HTTP'], - 'View' => ['Cache'], + 'RESTful' => ['+API', '+Controller'], + 'Router' => ['HTTP', 'I18n'], + 'Security' => ['Cookie', 'HTTP', 'I18n', 'Session'], + 'Session' => ['Cookie', 'Database', 'HTTP', 'I18n'], + 'Throttle' => ['Cache', 'I18n'], + 'Validation' => ['Database', 'HTTP', 'Helpers', 'I18n', 'Input'], + 'View' => ['Cache'], ]) ->skipPathsForRuleset(['*test*']) // Skip violations for class-specific dependencies. @@ -121,7 +140,7 @@ CastInterface::class, CastException::class, ]) - ->skipClassViolation(\CodeIgniter\DataCaster\Exceptions\CastException::class, [ + ->skipClassViolation(CodeIgniter\DataCaster\Exceptions\CastException::class, [ CastException::class, ]) ->skipClassViolation(DataConverter::class, [ @@ -149,4 +168,6 @@ ->skipClassViolation(Response::class, [PagerInterface::class]) ->skipClassViolation(RedirectResponse::class, [PagerInterface::class]) ->skipClassViolation(DownloadResponse::class, [PagerInterface::class]) + ->skipClassViolation(SSEResponse::class, [PagerInterface::class]) + ->skipClassViolation(StreamResponse::class, [PagerInterface::class]) ->skipClassViolation(Validation::class, [RendererInterface::class]); diff --git a/system/API/TransformerInterface.php b/system/API/TransformerInterface.php index 3d994251b302..7f8dd95759db 100644 --- a/system/API/TransformerInterface.php +++ b/system/API/TransformerInterface.php @@ -43,7 +43,7 @@ public function transform(array|object|null $resource): array; /** * Transforms a collection of resources using $this->transform() on each item. * - * @param array $resources + * @param array $resources * * @return array> */ diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php index 4666149f27e9..92ff2026bb19 100644 --- a/system/Autoloader/Autoloader.php +++ b/system/Autoloader/Autoloader.php @@ -531,7 +531,7 @@ 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 { + spl_autoload_register(static function ($class): void { $class = explode('\\', $class); if (array_shift($class) !== 'Kint') { diff --git a/system/Autoloader/FileLocatorCached.php b/system/Autoloader/FileLocatorCached.php index 3e31a33d9e84..ee1bb1577433 100644 --- a/system/Autoloader/FileLocatorCached.php +++ b/system/Autoloader/FileLocatorCached.php @@ -84,6 +84,7 @@ private function saveCache(): void */ public function deleteCache(): void { + $this->cache = []; $this->cacheUpdated = false; $this->cacheHandler->delete($this->cacheKey); } diff --git a/system/BaseModel.php b/system/BaseModel.php index 6f52c02a48c6..ad79f6f42ec6 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -1425,9 +1425,11 @@ protected function doProtectFieldsForInsert(array $row): array */ protected function setDate(?int $userDate = null) { - $currentDate = $userDate ?? Time::now()->getTimestamp(); + if ($userDate === null && $this->dateFormat === 'datetime') { + return $this->timeToDate(Time::now()); + } - return $this->intToDate($currentDate); + return $this->intToDate($userDate ?? Time::now()->getTimestamp()); } /** @@ -1648,9 +1650,7 @@ public function getValidationRules(array $options = []): array protected function ensureValidation(): void { - if ($this->validation === null) { - $this->validation = service('validation', null, false); - } + $this->validation ??= service('validation', null, false); } /** @@ -1855,7 +1855,7 @@ protected function objectToRawArray($object, bool $onlyChanged = true, bool $rec * * @param object|row_array|null $row * - * @return array + * @return array * * @throws DataException * @throws InvalidArgumentException @@ -1947,7 +1947,7 @@ public function __isset(string $name): bool /** * Provides direct access to method in the database connection. * - * @param array $params + * @param array $params * * @return mixed */ diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php index 57df52d47f55..72769dfd85a9 100644 --- a/system/CLI/BaseCommand.php +++ b/system/CLI/BaseCommand.php @@ -99,7 +99,7 @@ public function __construct(LoggerInterface $logger, Commands $commands) /** * Actually execute a command. * - * @param array $params + * @param array $params * * @return int|void */ @@ -108,7 +108,7 @@ abstract public function run(array $params); /** * Can be used by a command to run other commands. * - * @param array $params + * @param array $params * * @return int|void * diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 11f49bbc0c45..314b6a934ee1 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -254,12 +254,12 @@ public static function prompt(string $field, $options = null, $validation = null $default = $options[0]; } - static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': '); static::$lastWrite = 'write'; - // Read the input from keyboard. - $input = trim(static::$io->input()); - $input = ($input === '') ? (string) $default : $input; + // The reader renders the prompt itself, so readline redraws repaint it instead of erasing it. + $prompt = sprintf('%s%s%s: ', $field, trim($field) !== '' ? ' ' : '', $extraOutput); + $input = trim(static::$io->input($prompt)); + $input = $input === '' ? (string) $default : $input; if ($validation !== []) { while (! static::validate('"' . trim($field) . '"', $input, $validation)) { @@ -273,10 +273,10 @@ public static function prompt(string $field, $options = null, $validation = null /** * prompt(), but based on the option's key * - * @param list|string $text Output "field" text or an one or two value array where the first value is the text before listing the options - * and the second value the text before asking to select one option. Provide empty string to omit - * @param array $options A list of options (array(key => description)), the first option will be the default value - * @param list|string|null $validation Validation rules + * @param list|string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value + * @param list|string|null $validation Validation rules * * @return string The selected key of $options */ @@ -302,11 +302,11 @@ public static function promptByKey($text, array $options, $validation = null): s /** * This method is the same as promptByKey(), but this method supports multiple keys, separated by commas. * - * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options - * and the second value the text before asking to select one option. Provide empty string to omit - * @param array $options A list of options (array(key => description)), the first option will be the default value + * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value * - * @return array The selected key(s) and value(s) of $options + * @return array The selected key(s) and value(s) of $options */ public static function promptByMultipleKeys(string $text, array $options): array { @@ -376,7 +376,7 @@ public static function promptByMultipleKeys(string $text, array $options): array /** * Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array. * - * @param array $options + * @param array $options */ private static function isZeroOptions(array $options): void { @@ -388,7 +388,7 @@ private static function isZeroOptions(array $options): void /** * Print each key and value one by one * - * @param array $options + * @param array $options */ private static function printKeysAndValues(array $options): void { @@ -481,7 +481,7 @@ public static function error(string $text, string $foreground = 'light_red', ?st // Check color support for STDERR $stdout = static::$isColored; - static::$isColored = static::hasColorSupport(STDERR); + static::$isColored = is_cli() && static::hasColorSupport(STDERR); if ($foreground !== '' || (string) $background !== '') { $text = static::color($text, $foreground, $background); @@ -493,7 +493,7 @@ public static function error(string $text, string $foreground = 'light_red', ?st static::$lastWrite = 'write'; } - static::fwrite(STDERR, $text . PHP_EOL); + static::fwrite(is_cli() ? STDERR : STDOUT, $text . PHP_EOL); // return STDOUT color support static::$isColored = $stdout; @@ -572,7 +572,7 @@ public static function clearScreen() { // Unix systems, and Windows with VT100 Terminal support (i.e. Win10) // can handle CSI sequences. For lower than Win10 we just shove in 40 new lines. - is_windows() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT) + is_windows() && is_cli() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT) ? static::newLine(40) : static::fwrite(STDOUT, "\033[H\033[2J"); } @@ -741,7 +741,7 @@ public static function getWidth(int $default = 80): int static::generateDimensions(); } - return static::$width ?: $default; + return (static::$width === null || static::$width === 0) ? $default : static::$width; } /** @@ -753,7 +753,7 @@ public static function getHeight(int $default = 32): int static::generateDimensions(); } - return static::$height ?: $default; + return (static::$height === null || static::$height === 0) ? $default : static::$height; } /** @@ -819,7 +819,7 @@ public static function showProgress($thisStep = 1, int $totalSteps = 10) if ($thisStep !== false) { // Don't allow div by zero or negative numbers.... $thisStep = abs($thisStep); - $totalSteps = $totalSteps < 1 ? 1 : $totalSteps; + $totalSteps = max(1, $totalSteps); $percent = (int) (($thisStep / $totalSteps) * 100); $step = (int) round($percent / 10); @@ -1029,8 +1029,8 @@ public static function getOptionString(bool $useLongOpts = false, bool $trim = f /** * Returns a well formatted table * - * @param list> $tbody List of rows - * @param list $thead List of columns + * @param list> $tbody List of rows + * @param list $thead List of columns * * @return void */ diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 8f623d509c0a..bbccf6525f15 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -54,7 +54,7 @@ public function __construct($logger = null) /** * Runs a command given * - * @param array $params + * @param array $params * * @return int Exit code */ diff --git a/system/CLI/Console.php b/system/CLI/Console.php index b24562e52d46..82c31d60f7c5 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -74,9 +74,9 @@ public function showHeader(bool $suppress = false) * We'll remove that as an option from `$params` and * unshift it as argument instead. * - * @param array $params + * @param array $params * - * @return array + * @return array */ private function parseParamsForHelpOption(array $params): array { diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index 7d66b8b290af..45e72c7bda71 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -96,14 +96,14 @@ trait GeneratorTrait * * @internal * - * @var array + * @var array */ private $params = []; /** * Execute the command. * - * @param array $params + * @param array $params * * @deprecated use generateClass() instead */ @@ -115,7 +115,7 @@ protected function execute(array $params): void /** * Generates a class file from an existing template. * - * @param array $params + * @param array $params */ protected function generateClass(array $params): void { @@ -138,8 +138,8 @@ protected function generateClass(array $params): void /** * Generate a view file from an existing template. * - * @param string $view namespaced view name that is generated - * @param array $params + * @param string $view namespaced view name that is generated + * @param array $params */ protected function generateView(string $view, array $params): void { diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php index b69c19e2eee1..893b98a24595 100644 --- a/system/CLI/InputOutput.php +++ b/system/CLI/InputOutput.php @@ -43,15 +43,27 @@ public function input(?string $prefix = null): string { // readline() can't be tested. if ($this->readlineSupport && ENVIRONMENT !== 'testing') { - return readline($prefix); // @codeCoverageIgnore + // @codeCoverageIgnoreStart + $prompt = $this->readlinePrompt($prefix, readline_info('library_version')); + + if ($prompt === null) { + // The library cannot render the prompt, so write it ourselves and let readline() only read the line. + self::fwrite(STDOUT, $prefix ?? ''); + } + + $input = readline($prompt); + + return $input === false ? '' : $input; + // @codeCoverageIgnoreEnd } - echo $prefix; + // self:: skips MockInputOutput's fwrite override, whose filter bookkeeping must not nest inside input(). + self::fwrite(STDOUT, $prefix ?? ''); $input = fgets(fopen('php://stdin', 'rb')); if ($input === false) { - $input = ''; + return ''; } return $input; @@ -77,4 +89,37 @@ public function fwrite($handle, string $string): void fwrite($handle, $string); } + + /** + * Builds the prompt handed to readline(), or returns null when the caller has to write + * the prompt to STDOUT itself because the line-editing library cannot render it. + * + * GNU readline gets ANSI sequences wrapped in its non-printing markers so line-redraw + * column accounting skips them. Libedit reports "EditLine wrapper" and mangles the + * markers, so it gets the raw prompt. Windows builds use WinEditLine, which prints ANSI + * sequences literally, and readline_info() omits the version there (php-src guards it + * with #ifndef PHP_WIN32), so they get no prompt. + * + * @param mixed $libraryVersion The value of readline_info('library_version') + */ + private function readlinePrompt(?string $prefix, mixed $libraryVersion): ?string + { + if ($prefix === null || ! is_string($libraryVersion)) { + return null; + } + + if (str_contains($libraryVersion, 'EditLine')) { + return $prefix; + } + + return $this->markAnsiNonPrinting($prefix); + } + + /** + * Wraps ANSI escape sequences in readline's non-printing markers so line-redraw column accounting skips them. + */ + private function markAnsiNonPrinting(string $prefix): string + { + return preg_replace('/(\e\[[0-9;]*m)/', "\x01\$1\x02", $prefix); + } } diff --git a/system/CLI/SignalTrait.php b/system/CLI/SignalTrait.php index 0490e15d9d55..2bc69d51e93c 100644 --- a/system/CLI/SignalTrait.php +++ b/system/CLI/SignalTrait.php @@ -81,9 +81,7 @@ protected function isPcntlAvailable(): bool */ protected function isPosixAvailable(): bool { - if (self::$isPosixAvailable === null) { - self::$isPosixAvailable = is_windows() ? false : extension_loaded('posix'); - } + self::$isPosixAvailable ??= is_windows() ? false : extension_loaded('posix'); return self::$isPosixAvailable; } @@ -396,7 +394,7 @@ protected function getProcessState(): array if ($this->isPosixAvailable()) { $state['session_id'] = posix_getsid($pid); $state['process_group'] = posix_getpgid($pid); - $state['has_controlling_terminal'] = posix_isatty(STDIN); + $state['has_controlling_terminal'] = is_cli() && posix_isatty(STDIN); } return $state; diff --git a/system/Cache/FactoriesCache/FileVarExportHandler.php b/system/Cache/FactoriesCache/FileVarExportHandler.php index 023910c4b17d..241399a12035 100644 --- a/system/Cache/FactoriesCache/FileVarExportHandler.php +++ b/system/Cache/FactoriesCache/FileVarExportHandler.php @@ -23,7 +23,7 @@ public function save(string $key, mixed $val): void // Two processes may try to create the directory at the same time. // is_dir() confirms it exists, so suppressing the warning is safe. - if (! is_dir($this->path) && ! @mkdir($this->path, 0777, true) && ! is_dir($this->path)) { + if (! is_dir($this->path) && ! @mkdir($this->path, 0755, true) && ! is_dir($this->path)) { log_message('error', 'FactoriesCache: cannot create cache directory: ' . $this->path); return; @@ -37,6 +37,8 @@ public function save(string $key, mixed $val): void return; } + @chmod($tmp, 0644); + // Another process may have wiped the directory. Clean up on failure. if (! @rename($tmp, $this->path . "/{$key}")) { log_message('warning', 'FactoriesCache: failed to commit cache file for key: ' . $key); diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 6d0c8aec4d97..3633dc8e40da 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -180,9 +180,11 @@ public function decrement(string $key, int $offset = 1): false|int $key = static::validateKey($key, $this->prefix); - // FIXME: third parameter isn't other handler actions. - - return $this->memcached->decrement($key, $offset, $offset, 60); + // Memcached counters are unsigned and saturate at 0, so a missing + // key can only ever be initialized to 0, never a negative value. + // Use Memcached::decrement()'s own default of 0 as the initial + // value instead of $offset, so a new key no longer starts positive. + return $this->memcached->decrement($key, $offset, 0, 60); } public function clean(): bool diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index b806fe00a740..c3725f2388ad 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -55,7 +55,7 @@ class CodeIgniter /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.7.4'; + public const CI_VERSION = '4.7.5'; /** * App startup time. @@ -261,7 +261,7 @@ 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 { + spl_autoload_register(static function ($class): void { $class = explode('\\', $class); if (array_shift($class) !== 'Kint') { @@ -505,29 +505,29 @@ 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); - } - // Closure controller has run in startController(). - elseif (! is_callable($this->controller)) { - $controller = $this->createController(); - - if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) { - throw PageNotFoundException::forMethodNotFound($this->method); - } + } else { + // Closure controller has run in startController(). + if (! is_callable($this->controller)) { + $controller = $this->createController(); - // Is there a "post_controller_constructor" event? - Events::trigger('post_controller_constructor'); + if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) { + throw PageNotFoundException::forMethodNotFound($this->method); + } - $returned = $this->runController($controller); - } else { - $this->benchmark->stop('controller_constructor'); - $this->benchmark->stop('controller'); - } + // Is there a "post_controller_constructor" event? + Events::trigger('post_controller_constructor'); - // 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); + $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); + } if ($this->enableFilters) { /** @var Filters $filters */ $filters = service('filters'); @@ -624,9 +624,7 @@ protected function bootstrapEnvironment() */ protected function startBenchmark() { - if ($this->startTime === null) { - $this->startTime = microtime(true); - } + $this->startTime ??= microtime(true); $this->benchmark = Services::timer(); $this->benchmark->start('total_execution', $this->startTime); @@ -775,6 +773,8 @@ public function cachePage(Cache $config) /** * Returns an array with our basic performance stats collected. + * + * @return array{startTime: float|null, totalTime: float} */ public function getPerformanceStats(): array { diff --git a/system/Commands/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php index 32f9466a4939..a5c60d315ac5 100644 --- a/system/Commands/Cache/ClearCache.php +++ b/system/Commands/Cache/ClearCache.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Commands\Cache; +use CodeIgniter\Autoloader\FileLocatorCached; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use Config\Cache; @@ -81,6 +82,12 @@ public function run(array $params) return EXIT_ERROR; } + $locator = service('locator'); + + if ($handler === 'file' && $locator instanceof FileLocatorCached) { + $locator->deleteCache(); + } + CLI::write(CLI::color('Cache cleared.', 'green')); return EXIT_SUCCESS; diff --git a/system/Commands/Database/CreateDatabase.php b/system/Commands/Database/CreateDatabase.php index facd060abf6e..459b243d5632 100644 --- a/system/Commands/Database/CreateDatabase.php +++ b/system/Commands/Database/CreateDatabase.php @@ -79,7 +79,7 @@ public function run(array $params) { $name = array_shift($params); - if (empty($name)) { + if ($name === null || $name === '') { $name = CLI::prompt('Database name', null, 'required'); // @codeCoverageIgnore } diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php index b7863a001438..9ee39af58f1e 100644 --- a/system/Commands/Database/MigrateRefresh.php +++ b/system/Commands/Database/MigrateRefresh.php @@ -72,7 +72,7 @@ class MigrateRefresh extends BaseCommand */ public function run(array $params) { - $params['b'] = 0; + $params['b'] = '0'; if (ENVIRONMENT === 'production') { // @codeCoverageIgnoreStart diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php index 9506c2eae082..7307305307c1 100644 --- a/system/Commands/Database/MigrateStatus.php +++ b/system/Commands/Database/MigrateStatus.php @@ -77,8 +77,6 @@ class MigrateStatus extends BaseCommand /** * Displays a list of all migrations and whether they've been run or not. - * - * @param array $params */ public function run(array $params) { @@ -107,7 +105,7 @@ public function run(array $params) $migrations = $runner->findNamespaceMigrations($namespace); - if (empty($migrations)) { + if ($migrations === []) { continue; } diff --git a/system/Commands/Database/Seed.php b/system/Commands/Database/Seed.php index fc1c0f1ae62e..5a536d0f1b50 100644 --- a/system/Commands/Database/Seed.php +++ b/system/Commands/Database/Seed.php @@ -71,7 +71,7 @@ public function run(array $params) $seeder = new Seeder(new Database()); $seedName = array_shift($params); - if (empty($seedName)) { + if ($seedName === null || $seedName === '') { $seedName = CLI::prompt(lang('Migrations.migSeeder'), null, 'required'); // @codeCoverageIgnore } diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index 148fd264dc33..33858e5ab4c1 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -127,7 +127,7 @@ protected function generateRandomKey(string $prefix, int $length): string /** * Sets the new encryption key in your .env file. * - * @param array $params + * @param array $params */ protected function setNewEncryptionKey(string $key, array $params): bool { @@ -144,7 +144,7 @@ protected function setNewEncryptionKey(string $key, array $params): bool /** * Checks whether to overwrite existing encryption key. * - * @param array $params + * @param array $params */ protected function confirmOverwrite(array $params): bool { diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php index b5e64ec1bce1..5967c12c30af 100644 --- a/system/Commands/Generators/MigrationGenerator.php +++ b/system/Commands/Generators/MigrationGenerator.php @@ -89,10 +89,28 @@ public function run(array $params) if (array_key_exists('session', $params) || CLI::getOption('session')) { $table = $params['table'] ?? CLI::getOption('table') ?? 'ci_sessions'; $params[0] = "_create_{$table}_table"; + + $group = $params['dbgroup'] ?? CLI::getOption('dbgroup'); + $group = is_string($group) ? $group : 'default'; + $driver = config(Database::class)->{$group}['DBDriver'] ?? null; + + if ($driver === null) { + CLI::error(lang('CLI.generator.undefinedDatabaseGroup', [$group])); + + return EXIT_ERROR; + } + + if ($driver !== 'MySQLi' && $driver !== 'Postgre') { + CLI::error(lang('CLI.generator.unsupportedSessionDriver', [$group, $driver])); + + return EXIT_ERROR; + } } $this->classNameLang = 'CLI.generator.className.migration'; $this->generateClass($params); + + return EXIT_SUCCESS; } /** diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php index 8761e2bb7bbc..20ee5305ecaa 100644 --- a/system/Commands/ListCommands.php +++ b/system/Commands/ListCommands.php @@ -15,12 +15,15 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Commands; /** * 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. + * + * @phpstan-import-type commands_list from Commands */ class ListCommands extends BaseCommand { @@ -88,6 +91,8 @@ public function run(array $params) /** * Lists the commands with accompanying info. * + * @param commands_list $commands + * * @return int */ protected function listFull(array $commands) @@ -96,9 +101,7 @@ protected function listFull(array $commands) $groups = []; foreach ($commands as $title => $command) { - if (! isset($groups[$command['group']])) { - $groups[$command['group']] = []; - } + $groups[$command['group']] ??= []; $groups[$command['group']][$title] = $command; } @@ -133,6 +136,8 @@ protected function listFull(array $commands) /** * Lists the commands only. * + * @param commands_list $commands + * * @return int */ protected function listSimple(array $commands) diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php index ca7a1fb20230..8fed993a6ecc 100644 --- a/system/Commands/Translation/LocalizationFinder.php +++ b/system/Commands/Translation/LocalizationFinder.php @@ -213,7 +213,7 @@ private function findResolvedTranslations(string $langFileName, string $currentL /** * @param SplFileInfo|string $file * - * @return array + * @return array{foundLanguageKeys: array>, badLanguageKeys: array>} */ private function findTranslationsInFile($file): array { @@ -273,6 +273,9 @@ private function isIgnoredFile(SplFileInfo $file): bool return $file->getExtension() !== 'php'; } + /** + * @param array $language + */ private function templateFile(array $language = []): string { if ($language !== []) { @@ -337,6 +340,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 +363,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 { diff --git a/system/Commands/Utilities/FilterCheck.php b/system/Commands/Utilities/FilterCheck.php index e2043fe9a3a4..5013b7ddf054 100644 --- a/system/Commands/Utilities/FilterCheck.php +++ b/system/Commands/Utilities/FilterCheck.php @@ -108,7 +108,7 @@ public function run(array $params) } /** - * @param array $params + * @param array $params */ private function checkParams(array $params): bool { diff --git a/system/Commands/Utilities/Namespaces.php b/system/Commands/Utilities/Namespaces.php index 8dda8ce0732e..51bc045759ff 100644 --- a/system/Commands/Utilities/Namespaces.php +++ b/system/Commands/Utilities/Namespaces.php @@ -91,9 +91,14 @@ public function run(array $params) CLI::table($tbody, $thead); } + /** + * @param array $params + * + * @return list + */ private function outputAllNamespaces(array $params): array { - $maxLength = $params['m']; + $maxLength = (int) $params['m']; $autoloader = service('autoloader'); @@ -129,9 +134,14 @@ private function truncate(string $string, int $max): string return $string; } + /** + * @param array $params + * + * @return list + */ private function outputCINamespaces(array $params): array { - $maxLength = $params['m']; + $maxLength = (int) $params['m']; $config = new Autoload(); @@ -145,7 +155,8 @@ private function outputCINamespaces(array $params): array $pathOutput = $this->truncate(clean_path($path), $maxLength); } - $path = realpath($path) ?: $path; + $realPath = realpath($path); + $path = $realPath === false ? $path : $realPath; $tbody[] = [ $ns, diff --git a/system/Commands/Utilities/Optimize.php b/system/Commands/Utilities/Optimize.php index 1a103a1b9c52..9d3964ddda1d 100644 --- a/system/Commands/Utilities/Optimize.php +++ b/system/Commands/Utilities/Optimize.php @@ -74,7 +74,12 @@ public function run(array $params) private function clearCache(): void { - $locator = new FileLocatorCached(new FileLocator(service('autoloader'))); + $locator = service('locator'); + + if (! $locator instanceof FileLocatorCached) { + $locator = new FileLocatorCached(new FileLocator(service('autoloader'))); + } + $locator->deleteCache(); CLI::write('Removed FileLocatorCache.', 'green'); diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php index 60a79011426a..a19bd5f24188 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php @@ -95,9 +95,9 @@ public function get(): array /** * Adding Filters * - * @param list> $routes + * @param list, before?: string, after?: string}> $routes * - * @return list> + * @return list, before: string, after: string}> */ private function addFilters(array $routes): array { @@ -134,6 +134,9 @@ private function addFilters(array $routes): array return $routes; } + /** + * @param array{method: string, route: string, route_params: string, handler: string, params: array, before?: string, after?: string} $route + */ private function generateSampleUri(array $route, bool $longest = true): string { $sampleUri = ''; diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php index 599e90f4d2f3..0863388b8af5 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php @@ -45,7 +45,7 @@ public function __construct( * * @param class-string $class * - * @return list> + * @return list}> */ public function read(string $class, string $defaultController = 'Home', string $defaultMethod = 'index'): array { @@ -128,6 +128,9 @@ public function read(string $class, string $defaultController = 'Home', string $ return $output; } + /** + * @return array{array, string} + */ private function getParameters(ReflectionMethod $method): array { $params = []; diff --git a/system/Commands/Utilities/Routes/ControllerMethodReader.php b/system/Commands/Utilities/Routes/ControllerMethodReader.php index 7c8faffd16be..aa5cddaf48d1 100644 --- a/system/Commands/Utilities/Routes/ControllerMethodReader.php +++ b/system/Commands/Utilities/Routes/ControllerMethodReader.php @@ -147,6 +147,8 @@ private function getUriByClass(string $classname): string /** * Gets a route without default controller. + * + * @return list */ private function getRouteWithoutController( string $classShortname, diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php index 98a1e3659eaf..6477e1d13300 100644 --- a/system/Commands/Utilities/Routes/FilterFinder.php +++ b/system/Commands/Utilities/Routes/FilterFinder.php @@ -36,6 +36,9 @@ public function __construct(?Router $router = null, ?Filters $filters = null) $this->filters = $filters ?? service('filters'); } + /** + * @return list + */ private function getRouteFilters(string $uri): array { $this->router->handle($uri); diff --git a/system/Common.php b/system/Common.php index fc8abb394f06..72502d0fd5d0 100644 --- a/system/Common.php +++ b/system/Common.php @@ -95,7 +95,8 @@ function clean_path(string $path): string { // Resolve relative paths try { - $path = realpath($path) ?: $path; + $realPath = realpath($path); + $path = $realPath === false ? $path : $realPath; } catch (ErrorException|ValueError) { $path = 'error file path: ' . urlencode($path); } @@ -154,7 +155,7 @@ function command(string $command) $cursor += strlen($match[0]); } - /** @var array */ + /** @var array */ $params = []; $command = array_shift($args); $optionValue = false; @@ -450,13 +451,13 @@ function env(string $key, $default = null) * If $data is an array, then it loops over it, escaping each * 'value' of the key/value pairs. * - * @param array|string>|string $data - * @param 'attr'|'css'|'html'|'js'|'raw'|'url' $context - * @param string|null $encoding Current encoding for escaping. - * If not UTF-8, we convert strings from this encoding - * pre-escaping and back to this encoding post-escaping. + * @param array|string>|string $data + * @param 'attr'|'css'|'html'|'js'|'raw'|'url' $context + * @param string|null $encoding Current encoding for escaping. + * If not UTF-8, we convert strings from this encoding + * pre-escaping and back to this encoding post-escaping. * - * @return ($data is string ? string : array|string>) + * @return ($data is string ? string : array|string>) * * @throws InvalidArgumentException */ @@ -489,9 +490,7 @@ function esc($data, string $context = 'html', ?string $encoding = null) static $escapers = []; $cacheKey = strtolower($encoding ?? 'utf-8'); - if (! isset($escapers[$cacheKey])) { - $escapers[$cacheKey] = new Escaper($encoding); - } + $escapers[$cacheKey] ??= new Escaper($encoding); $data = $escapers[$cacheKey]->{$method}($data); } @@ -587,9 +586,7 @@ function function_usable(string $functionName): bool static $_suhosin_func_blacklist; if (function_exists($functionName)) { - if (! isset($_suhosin_func_blacklist)) { - $_suhosin_func_blacklist = extension_loaded('suhosin') ? explode(',', trim(ini_get('suhosin.executor.func.blacklist'))) : []; - } + $_suhosin_func_blacklist ??= extension_loaded('suhosin') ? explode(',', trim(ini_get('suhosin.executor.func.blacklist'))) : []; return ! in_array($functionName, $_suhosin_func_blacklist, true); } @@ -818,6 +815,8 @@ function lang(string $line, array $args = [], ?string $locale = null) * - notice * - info * - debug + * + * @param array $context */ function log_message(string $level, string $message, array $context = []): void { @@ -860,7 +859,7 @@ function model(string $name, bool $getShared = true, ?ConnectionInterface &$conn * @param string|null $default * @param 'attr'|'css'|'html'|'js'|'raw'|'url'|false $escape * - * @return array|string|null + * @return array|string|null */ function old(string $key, $default = null, $escape = 'html') { @@ -1184,7 +1183,7 @@ function slash_item(string $item): ?string * Helper function used to convert a string, array, or object * of attributes to a string. * - * @param array|object|string $attributes string, array, object that can be cast to array + * @param array|object|string $attributes */ function stringify_attributes($attributes, bool $js = false): string { @@ -1251,7 +1250,8 @@ function timer(?string $name = null, ?callable $callable = null) * NOTE: Does not provide any escaping of the data, so that must * all be handled manually by the developer. * - * @param array $options Options for saveData or third-party extensions. + * @param array $data + * @param array $options Options for saveData or third-party extensions. */ function view(string $name, array $data = [], array $options = []): string { @@ -1274,7 +1274,7 @@ function view(string $name, array $data = [], array $options = []): string * View cells are used within views to insert HTML chunks that are managed * by other classes. * - * @param array|string|null $params + * @param array|string|null $params * * @throws ReflectionException */ @@ -1347,7 +1347,11 @@ function class_uses_recursive($class) */ function trait_uses_recursive($trait) { - $traits = class_uses($trait) ?: []; + $traits = class_uses($trait); + + if ($traits === false) { + return []; + } foreach ($traits as $trait) { $traits += trait_uses_recursive($trait); diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 40782a243b96..208797ce6d1c 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -37,7 +37,7 @@ class BaseConfig * An optional array of classes that will act as Registrars * for rapidly setting config class properties. * - * @var array + * @var list */ public static $registrars = []; @@ -70,6 +70,9 @@ class BaseConfig */ protected static $moduleConfig; + /** + * @param array $array + */ public static function __set_state(array $array) { static::$override = false; @@ -166,7 +169,7 @@ protected function parseEncryptionKey(string $key): string /** * Initialization an environment-specific configuration setting * - * @param array|bool|float|int|string|null $property + * @param array|bool|float|int|string|null $property * * @return void */ diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 64b5266da041..06cbabd10efd 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -134,7 +134,7 @@ * @method static Security security(App $config = null, $getShared = true) * @method static Session session(ConfigSession $config = null, $getShared = true) * @method static SiteURIFactory siteurifactory(App $config = null, Superglobals $superglobals = null, $getShared = true) - * @method static Superglobals superglobals(array $server = null, array $get = null, bool $getShared = true) + * @method static Superglobals superglobals(array $server = null, array $get = null, bool $getShared = true) * @method static Throttler throttler($getShared = true) * @method static Timer timer($getShared = true) * @method static Toolbar toolbar(ConfigToolbar $config = null, $getShared = true) @@ -178,7 +178,7 @@ class BaseService /** * A cache of other service classes we've found. * - * @var array + * @var array{} * * @deprecated 4.5.0 No longer used. */ @@ -276,9 +276,7 @@ protected static function getSharedInstance(string $key, ...$params) public static function autoloader(bool $getShared = true) { if ($getShared) { - if (empty(static::$instances['autoloader'])) { - static::$instances['autoloader'] = new Autoloader(); - } + static::$instances['autoloader'] ??= new Autoloader(); return static::$instances['autoloader']; } @@ -296,7 +294,7 @@ public static function autoloader(bool $getShared = true) public static function locator(bool $getShared = true) { if ($getShared) { - if (empty(static::$instances['locator'])) { + if (! isset(static::$instances['locator'])) { $cacheEnabled = class_exists(Optimize::class) && (new Optimize())->locatorCacheEnabled; @@ -317,6 +315,8 @@ public static function locator(bool $getShared = true) * Provides the ability to perform case-insensitive calling of service * names. * + * @param array $arguments + * * @return object|null */ public static function __callStatic(string $name, array $arguments) diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php index 6778c44e0d8d..a2f15e7c4850 100644 --- a/system/Config/DotEnv.php +++ b/system/Config/DotEnv.php @@ -51,6 +51,8 @@ public function load(): bool /** * Parse the .env file into an array of key => value + * + * @return array|null */ public function parse(): ?array { @@ -59,15 +61,24 @@ public function parse(): ?array return null; } - // Ensure the file is readable - if (! is_readable($this->path)) { - throw new InvalidArgumentException("The .env file is not readable: {$this->path}"); + $lines = @file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + // The .env file may have been removed or replaced by a concurrent + // process between the is_file() check above and this read attempt + // (e.g. another test process renaming `.env`). A vanished file is + // treated as absent, so re-check with a fresh stat cache. + if ($lines === false) { + clearstatcache(true, $this->path); + + if (is_file($this->path)) { + throw new InvalidArgumentException("The .env file is not readable: {$this->path}"); + } + + return null; } $vars = []; - $lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($lines as $line) { // Is it a comment? if (str_starts_with(trim($line), '#')) { @@ -98,11 +109,11 @@ protected function setVariable(string $name, string $value = '') putenv("{$name}={$value}"); } - if (empty($_ENV[$name])) { + if (! isset($_ENV[$name]) || in_array($_ENV[$name], ['', '0'], true)) { $_ENV[$name] = $value; } - if (empty($_SERVER[$name])) { + if (! isset($_SERVER[$name]) || in_array($_SERVER[$name], ['', '0'], true)) { $_SERVER[$name] = $value; } } @@ -110,6 +121,8 @@ protected function setVariable(string $name, string $value = '') /** * Parses for assignment, cleans the $name and $value, and ensures * that nested variables are handled. + * + * @return array{string, string} */ public function normaliseVariable(string $name, string $value = ''): array { @@ -195,7 +208,7 @@ protected function sanitizeValue(string $value): string protected function resolveNestedVariables(string $value): string { if (str_contains($value, '$')) { - $value = preg_replace_callback( + return preg_replace_callback( '/\${([a-zA-Z0-9_\.]+)}/', function ($matchedPatterns) { $nestedVariable = $this->getVariable($matchedPatterns[1]); diff --git a/system/Config/Factories.php b/system/Config/Factories.php index ab5343d15fb8..1adbafc48e7b 100644 --- a/system/Config/Factories.php +++ b/system/Config/Factories.php @@ -26,8 +26,9 @@ * large performance boost and helps keep code clean of lengthy * instantiation checks. * + * @method static object|null cells(string $alias, array $options = []) * @method static BaseConfig|null config(...$arguments) - * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null) + * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null) * @see \CodeIgniter\Config\FactoriesTest */ final class Factories @@ -127,6 +128,8 @@ public static function define(string $component, string $alias, string $classnam * Loads instances based on the method component name. Either * creates a new instance or returns an existing shared instance. * + * @param array $arguments + * * @return object|null */ public static function __callStatic(string $component, array $arguments) @@ -140,7 +143,7 @@ public static function __callStatic(string $component, array $arguments) // Determine the component-specific options $options = array_merge(self::getOptions($component), $options); - if (! $options['getShared']) { + if (! (bool) $options['getShared']) { if (isset(self::$aliases[$options['component']][$alias])) { $class = self::$aliases[$options['component']][$alias]; @@ -192,6 +195,9 @@ public static function get(string $component, string $alias): ?object /** * Gets the defined instance. If not exists, creates new one. * + * @param array $options + * @param array $arguments + * * @return object|null */ private static function getDefinedInstance(array $options, string $alias, array $arguments) @@ -230,6 +236,8 @@ private static function getDefinedInstance(array $options, string $alias, array /** * Creates the shared instance. + * + * @param array $arguments */ private static function createInstance(string $component, string $class, array $arguments): void { @@ -264,8 +272,8 @@ private static function isConfig(string $component): bool /** * Finds a component class * - * @param array $options The array of component-specific directives - * @param string $alias Class alias. See the $aliases property. + * @param array $options The array of component-specific directives + * @param string $alias Class alias. See the $aliases property. */ private static function locateClass(array $options, string $alias): ?string { @@ -341,8 +349,8 @@ private static function isNamespaced(string $alias): bool /** * Verifies that a class & config satisfy the "preferApp" option * - * @param array $options The array of component-specific directives - * @param string $alias Class alias. See the $aliases property. + * @param array $options The array of component-specific directives + * @param string $alias Class alias. See the $aliases property. */ private static function verifyPreferApp(array $options, string $alias): bool { @@ -362,8 +370,8 @@ private static function verifyPreferApp(array $options, string $alias): bool /** * Verifies that a class & config satisfy the "instanceOf" option * - * @param array $options The array of component-specific directives - * @param string $alias Class alias. See the $aliases property. + * @param array $options The array of component-specific directives + * @param string $alias Class alias. See the $aliases property. */ private static function verifyInstanceOf(array $options, string $alias): bool { @@ -408,8 +416,8 @@ public static function getOptions(string $component): array /** * Normalizes, stores, and returns the configuration for a specific component * - * @param string $component Lowercase, plural component name - * @param array $values option values + * @param string $component Lowercase, plural component name + * @param array $values Option values * * @return array The result after applying defaults and normalization */ @@ -541,6 +549,12 @@ public static function getComponentInstances(string $component): array /** * Sets component data * + * @param array{ + * options: array, + * aliases: array, + * instances: array, + * } $data + * * @internal For caching only */ public static function setComponentInstances(string $component, array $data): void diff --git a/system/Config/Factory.php b/system/Config/Factory.php index b25267791beb..9203af73ff09 100644 --- a/system/Config/Factory.php +++ b/system/Config/Factory.php @@ -28,7 +28,7 @@ class Factory extends BaseConfig * Supplies a default set of options to merge for * all unspecified factory components. * - * @var array + * @var array */ public static $default = [ 'component' => null, @@ -42,7 +42,7 @@ class Factory extends BaseConfig * Specifies that Models should always favor child * classes to allow easy extension of module Models. * - * @var array + * @var array */ public $models = [ 'preferApp' => true, diff --git a/system/Config/Services.php b/system/Config/Services.php index cef65a8895a9..f5a11ac19a80 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -198,6 +198,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. * + * @param array $options + * * @return CURLRequest */ public static function curlrequest(array $options = [], ?ResponseInterface $response = null, ?App $config = null, bool $getShared = true) @@ -220,7 +222,7 @@ public static function curlrequest(array $options = [], ?ResponseInterface $resp /** * The Email class allows you to send email via mail, sendmail, SMTP. * - * @param array|EmailConfig|null $config + * @param array|EmailConfig|null $config * * @return Email */ @@ -230,7 +232,7 @@ public static function email($config = null, bool $getShared = true) return static::getSharedInstance('email', $config); } - if (empty($config) || (! is_array($config) && ! $config instanceof EmailConfig)) { + if (! $config instanceof EmailConfig && (! is_array($config) || $config === [])) { $config = config(EmailConfig::class); } @@ -343,7 +345,6 @@ public static function image(?string $handler = null, ?Images $config = null, bo } $config ??= config(Images::class); - assert($config instanceof Images); $handler = in_array($handler, [null, '', '0'], true) ? $config->defaultHandler : $handler; $class = $config->handlers[$handler]; @@ -740,6 +741,13 @@ public static function siteurifactory( /** * Superglobals. * + * @param array|null $server + * @param array|null $get + * @param array|null $post + * @param array|null $cookie + * @param array|null $files + * @param array|null $request + * * @return Superglobals */ public static function superglobals( diff --git a/system/Config/View.php b/system/Config/View.php index fcfe32f40f1c..2ec8668ae543 100644 --- a/system/Config/View.php +++ b/system/Config/View.php @@ -37,7 +37,7 @@ class View extends BaseConfig * * @psalm-suppress UndefinedDocblockClass * - * @var array + * @var array */ public $filters = []; @@ -52,6 +52,14 @@ class View extends BaseConfig */ public $plugins = []; + /** + * When true, Parser conditionals may only contain variables, literals, + * arithmetic, comparison and logical operators, and parentheses. Enable + * this when less-trusted users can edit Parser templates. Can be overridden per + * call with the `restrictConditionals` render option. + */ + public bool $restrictParserConditionals = false; + /** * Built-in View filters. * diff --git a/system/Controller.php b/system/Controller.php index af74f3a3af10..2fdb5d5a59a6 100644 --- a/system/Controller.php +++ b/system/Controller.php @@ -74,6 +74,8 @@ class Controller /** * Constructor. * + * @param CLIRequest|IncomingRequest $request + * * @return void * * @throws HTTPException|RedirectException @@ -126,8 +128,8 @@ protected function cachePage(int $time) /** * A shortcut to performing validation on Request data. * - * @param array|string $rules - * @param array $messages An array of custom error messages + * @param array|string $rules + * @param array|string> $messages An array of custom error messages */ protected function validate($rules, array $messages = []): bool { @@ -139,10 +141,10 @@ protected function validate($rules, array $messages = []): bool /** * A shortcut to performing validation on any input data. * - * @param array $data The data to validate - * @param array|string $rules - * @param array $messages An array of custom error messages - * @param string|null $dbGroup The database group to use + * @param array $data The data to validate + * @param array|string $rules + * @param array|string> $messages An array of custom error messages + * @param string|null $dbGroup The database group to use */ protected function validateData(array $data, $rules, array $messages = [], ?string $dbGroup = null): bool { @@ -152,7 +154,8 @@ protected function validateData(array $data, $rules, array $messages = [], ?stri } /** - * @param array|string $rules + * @param array|string $rules + * @param array|string> $messages */ private function setValidator($rules, array $messages): void { diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php index e144d84ba031..da95f18805d4 100644 --- a/system/Cookie/Cookie.php +++ b/system/Cookie/Cookie.php @@ -127,7 +127,19 @@ class Cookie implements ArrayAccess, CloneableCookieInterface * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes * @see https://tools.ietf.org/html/rfc2616#section-2.2 */ - private static string $reservedCharsList = "=,; \t\r\n\v\f()<>@:\\\"/[]?{}"; + private static string $reservedCharsList = "=,; \t\r\n\v\f\0()<>@:\\\"/[]?{}"; + + /** + * Prohibited characters in cookie prefix and name per PHP setcookie() constraints. + * + * @see https://www.php.net/manual/en/function.setcookie.php + */ + private static string $reservedPrefixCharsList = "=,; \t\r\n\v\f\0"; + + /** + * @see https://www.php.net/manual/en/function.setrawcookie.php + */ + private static string $reservedValueCharsList = ",; \t\r\n\v\f\0"; /** * Set the default attributes to a Cookie instance by injecting @@ -227,11 +239,11 @@ public static function fromHeaderString(string $cookie, bool $raw = false) * @param string $name The cookie's name * @param string $value The cookie's value * @param array{ - * prefix?: string, + * prefix?: string|null, * max-age?: int|numeric-string, * expires?: DateTimeInterface|int|string, - * path?: string, - * domain?: string, + * path?: string|null, + * domain?: string|null, * secure?: bool, * httponly?: bool, * samesite?: string, @@ -253,18 +265,21 @@ final public function __construct(string $name, string $value = '', array $optio } // to preserve backward compatibility with array-based cookies in previous CI versions - $prefix = ($options['prefix'] === '') ? self::$defaults['prefix'] : $options['prefix']; - $path = $options['path'] ?: self::$defaults['path']; - $domain = $options['domain'] ?: self::$defaults['domain']; + $prefix = in_array($options['prefix'], [null, ''], true) ? self::$defaults['prefix'] : $options['prefix']; + $path = in_array($options['path'], [null, '', '0'], true) ? self::$defaults['path'] : $options['path']; + $domain = in_array($options['domain'], [null, ''], true) ? self::$defaults['domain'] : $options['domain']; // empty string SameSite should use the default for browsers - $samesite = $options['samesite'] ?: self::$defaults['samesite']; + $samesite = ($options['samesite'] === '') ? self::$defaults['samesite'] : $options['samesite']; $raw = $options['raw']; $secure = $options['secure']; $httponly = $options['httponly']; $this->validateName($name, $raw); + $this->validateValue($value, $raw); + $this->validatePath($path); + $this->validateDomain($domain); $this->validatePrefix($prefix, $secure, $path, $domain); $this->validateSameSite($samesite, $secure); @@ -366,7 +381,7 @@ public function getMaxAge(): int { $maxAge = $this->expires - Time::now()->getTimestamp(); - return $maxAge >= 0 ? $maxAge : 0; + return max($maxAge, 0); } /** @@ -429,7 +444,7 @@ public function getOptions(): array 'domain' => $this->domain, 'secure' => $this->secure, 'httponly' => $this->httponly, - 'samesite' => $this->samesite ?: ucfirst(self::SAMESITE_LAX), + 'samesite' => ($this->samesite === '') ? ucfirst(self::SAMESITE_LAX) : $this->samesite, ]; } @@ -470,6 +485,8 @@ public function withName(string $name) */ public function withValue(string $value) { + $this->validateValue($value, $this->raw); + $cookie = clone $this; $cookie->value = $value; @@ -507,6 +524,7 @@ public function withExpired() public function withPath(?string $path) { $path = in_array($path, [null, '', '0'], true) ? self::$defaults['path'] : $path; + $this->validatePath($path); $this->validatePrefix($this->prefix, $this->secure, $path, $this->domain); $cookie = clone $this; @@ -522,6 +540,7 @@ public function withPath(?string $path) public function withDomain(?string $domain) { $domain ??= self::$defaults['domain']; + $this->validateDomain($domain); $this->validatePrefix($this->prefix, $this->secure, $this->path, $domain); $cookie = clone $this; @@ -578,6 +597,7 @@ public function withSameSite(string $samesite) public function withRaw(bool $raw = true) { $this->validateName($this->name, $raw); + $this->validateValue($this->value, $raw); $cookie = clone $this; @@ -619,7 +639,7 @@ public function offsetGet($offset): bool|int|string /** * Offset to set. * - * @param string $offset + * @param string|null $offset * @param bool|int|string $value * * @throws LogicException @@ -767,12 +787,56 @@ protected function validateName(string $name, bool $raw): void } /** - * Validates the special prefixes if some attribute requirements are met. + * Validates the cookie value. + * + * If `$raw` is true, values should not contain invalid characters + * as `setrawcookie()` will reject this. + * + * @throws CookieException + */ + protected function validateValue(string $value, bool $raw): void + { + if ($raw && strpbrk($value, self::$reservedValueCharsList) !== false) { + throw CookieException::forInvalidCookieValue(); + } + } + + /** + * Validates the cookie path per PHP setcookie() constraints. + * + * @throws CookieException + */ + protected function validatePath(string $path): void + { + if (strpbrk($path, self::$reservedValueCharsList) !== false) { + throw CookieException::forInvalidCookiePath(); + } + } + + /** + * Validates the cookie domain per PHP setcookie() constraints. + * + * @throws CookieException + */ + protected function validateDomain(string $domain): void + { + if ($domain !== '' && strpbrk($domain, self::$reservedValueCharsList) !== false) { + throw CookieException::forInvalidCookieDomain(); + } + } + + /** + * Validates the special prefixes if some attribute requirements are met, + * and ensures the prefix contains no PHP-prohibited characters. * * @throws CookieException */ protected function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void { + if (strpbrk($prefix, self::$reservedPrefixCharsList) !== false) { + throw CookieException::forInvalidCookieName($prefix); + } + if (str_starts_with($prefix, '__Secure-') && ! $secure) { throw CookieException::forInvalidSecurePrefix(); } diff --git a/system/Cookie/CookieStore.php b/system/Cookie/CookieStore.php index c4b2994d9cf3..ece407e98d80 100644 --- a/system/Cookie/CookieStore.php +++ b/system/Cookie/CookieStore.php @@ -196,6 +196,8 @@ public function getIterator(): Traversable /** * Validates all cookies passed to be instances of Cookie. * + * @param array $cookies + * * @throws CookieException */ protected function validateCookies(array $cookies): void diff --git a/system/Cookie/Exceptions/CookieException.php b/system/Cookie/Exceptions/CookieException.php index 8b6c7575983a..b05aa579e61b 100644 --- a/system/Cookie/Exceptions/CookieException.php +++ b/system/Cookie/Exceptions/CookieException.php @@ -60,6 +60,36 @@ public static function forEmptyCookieName() return new static(lang('Cookie.emptyCookieName')); } + /** + * Thrown when the cookie value contains invalid characters. + * + * @return static + */ + public static function forInvalidCookieValue() + { + return new static(lang('Cookie.invalidCookieValue')); + } + + /** + * Thrown when the cookie path contains invalid characters. + * + * @return static + */ + public static function forInvalidCookiePath() + { + return new static(lang('Cookie.invalidCookiePath')); + } + + /** + * Thrown when the cookie domain contains invalid characters. + * + * @return static + */ + public static function forInvalidCookieDomain() + { + return new static(lang('Cookie.invalidCookieDomain')); + } + /** * Thrown when using the `__Secure-` prefix but the `Secure` attribute * is not set to true. diff --git a/system/DataCaster/Cast/ArrayCast.php b/system/DataCaster/Cast/ArrayCast.php index 1deddea6fc8e..81f343b3e4a8 100644 --- a/system/DataCaster/Cast/ArrayCast.php +++ b/system/DataCaster/Cast/ArrayCast.php @@ -14,12 +14,12 @@ namespace CodeIgniter\DataCaster\Cast; /** - * Class ArrayCast - * * (PHP) [array --> string] --> (DB driver) --> (DB column) string * [ <-- string] <-- (DB driver) <-- (DB column) string + * + * @extends BaseCast, string> */ -class ArrayCast extends BaseCast implements CastInterface +class ArrayCast extends BaseCast { public static function get( mixed $value, diff --git a/system/DataCaster/Cast/BaseCast.php b/system/DataCaster/Cast/BaseCast.php index 06fb0eb4fa69..e6cc5ae98f1c 100644 --- a/system/DataCaster/Cast/BaseCast.php +++ b/system/DataCaster/Cast/BaseCast.php @@ -15,6 +15,12 @@ use CodeIgniter\Exceptions\InvalidArgumentException; +/** + * @template TPhpNativeValue + * @template TDataSourceValue + * + * @implements CastInterface + */ abstract class BaseCast implements CastInterface { public static function get( diff --git a/system/DataCaster/Cast/BooleanCast.php b/system/DataCaster/Cast/BooleanCast.php index 6554081e155d..79b9dfe6d8d9 100644 --- a/system/DataCaster/Cast/BooleanCast.php +++ b/system/DataCaster/Cast/BooleanCast.php @@ -14,10 +14,10 @@ namespace CodeIgniter\DataCaster\Cast; /** - * Class BooleanCast - * * (PHP) [bool --> bool ] --> (DB driver) --> (DB column) bool|int(0/1) * [ <-- string|int] <-- (DB driver) <-- (DB column) bool|int(0/1) + * + * @extends BaseCast */ class BooleanCast extends BaseCast { diff --git a/system/DataCaster/Cast/CSVCast.php b/system/DataCaster/Cast/CSVCast.php index d77c64c87b57..3c09e1cc6103 100644 --- a/system/DataCaster/Cast/CSVCast.php +++ b/system/DataCaster/Cast/CSVCast.php @@ -14,10 +14,10 @@ namespace CodeIgniter\DataCaster\Cast; /** - * Class CSVCast - * * (PHP) [array --> string] --> (DB driver) --> (DB column) string * [ <-- string] <-- (DB driver) <-- (DB column) string + * + * @extends BaseCast, string> */ class CSVCast extends BaseCast { diff --git a/system/DataCaster/Cast/CastInterface.php b/system/DataCaster/Cast/CastInterface.php index 7f5b05102a7d..6f2a38d75c9e 100644 --- a/system/DataCaster/Cast/CastInterface.php +++ b/system/DataCaster/Cast/CastInterface.php @@ -13,35 +13,31 @@ namespace CodeIgniter\DataCaster\Cast; +/** + * @template TPhpNativeValue + * @template TDataSourceValue + */ interface CastInterface { /** - * Takes a value from DataSource, returns its value for PHP. + * Takes a value from a data source, returns its value for PHP. * - * @param mixed $value Data from database driver + * @param TDataSourceValue $value Data from database driver * @param array $params Additional param * @param object|null $helper Helper object. E.g., database connection * - * @return mixed PHP native value + * @return TPhpNativeValue */ - public static function get( - mixed $value, - array $params = [], - ?object $helper = null, - ): mixed; + public static function get(mixed $value, array $params = [], ?object $helper = null): mixed; /** - * Takes a PHP value, returns its value for DataSource. + * Takes a PHP value, returns its value for a data source. * - * @param mixed $value PHP native value + * @param TPhpNativeValue $value PHP native value * @param array $params Additional param * @param object|null $helper Helper object. E.g., database connection * - * @return mixed Data to pass to database driver + * @return TDataSourceValue */ - public static function set( - mixed $value, - array $params = [], - ?object $helper = null, - ): mixed; + public static function set(mixed $value, array $params = [], ?object $helper = null): mixed; } diff --git a/system/DataCaster/Cast/DatetimeCast.php b/system/DataCaster/Cast/DatetimeCast.php index 398fdc7beed7..043b7ccd81ea 100644 --- a/system/DataCaster/Cast/DatetimeCast.php +++ b/system/DataCaster/Cast/DatetimeCast.php @@ -18,10 +18,10 @@ use CodeIgniter\I18n\Time; /** - * Class DatetimeCast - * * (PHP) [Time --> string] --> (DB driver) --> (DB column) datetime * [ <-- string] <-- (DB driver) <-- (DB column) datetime + * + * @extends BaseCast */ class DatetimeCast extends BaseCast { diff --git a/system/DataCaster/Cast/EnumCast.php b/system/DataCaster/Cast/EnumCast.php index 454251dcba6b..fcf611fe63d2 100644 --- a/system/DataCaster/Cast/EnumCast.php +++ b/system/DataCaster/Cast/EnumCast.php @@ -19,14 +19,14 @@ use UnitEnum; /** - * Class EnumCast - * * Handles casting for PHP enums (both backed and unit enums) * * (PHP) [enum --> value/name] --> (DB driver) --> (DB column) int|string * [ <-- value/name] <-- (DB driver) <-- (DB column) int|string + * + * @extends BaseCast */ -class EnumCast extends BaseCast implements CastInterface +class EnumCast extends BaseCast { public static function get( mixed $value, @@ -49,9 +49,7 @@ public static function get( $reflection = new ReflectionEnum($enumClass); - // Unit enum - if (! $reflection->isBacked()) { - // Unit enum - match by name + if (! is_a($enumClass, BackedEnum::class, true)) { foreach ($enumClass::cases() as $case) { if ($case->name === $value) { return $case; @@ -61,10 +59,8 @@ public static function get( throw CastException::forInvalidEnumCaseName($enumClass, $value); } - // Backed enum - validate and cast the value to proper type $backingType = $reflection->getBackingType(); - // Cast to proper type (int or string) if ($backingType->getName() === 'int') { $value = (int) $value; } elseif ($backingType->getName() === 'string') { @@ -89,7 +85,6 @@ public static function set( self::invalidTypeValueError($value); } - // Get the expected enum class $enumClass = $params[0] ?? null; if ($enumClass === null) { @@ -100,21 +95,14 @@ public static function set( throw CastException::forNotEnum($enumClass); } - // Validate that the enum is of the expected type if (! $value instanceof $enumClass) { throw CastException::forInvalidEnumType($enumClass, $value::class); } - $reflection = new ReflectionEnum($value::class); - - // Backed enum - return the properly typed backing value - if ($reflection->isBacked()) { - /** @var BackedEnum $value */ + if ($value instanceof BackedEnum) { return $value->value; } - // Unit enum - return the case name - /** @var UnitEnum $value */ return $value->name; } } diff --git a/system/DataCaster/Cast/FloatCast.php b/system/DataCaster/Cast/FloatCast.php index d2173826265a..72d7db968e5f 100644 --- a/system/DataCaster/Cast/FloatCast.php +++ b/system/DataCaster/Cast/FloatCast.php @@ -14,10 +14,10 @@ namespace CodeIgniter\DataCaster\Cast; /** - * Class FloatCast - * * (PHP) [float --> float ] --> (DB driver) --> (DB column) float * [ <-- float|string] <-- (DB driver) <-- (DB column) float + * + * @extends BaseCast */ class FloatCast extends BaseCast { diff --git a/system/DataCaster/Cast/IntBoolCast.php b/system/DataCaster/Cast/IntBoolCast.php index 5aa1f2b2eb93..49b8861a4c6e 100644 --- a/system/DataCaster/Cast/IntBoolCast.php +++ b/system/DataCaster/Cast/IntBoolCast.php @@ -18,6 +18,8 @@ * * (PHP) [bool --> int ] --> (DB driver) --> (DB column) int(0/1) * [ <-- int|string] <-- (DB driver) <-- (DB column) int(0/1) + * + * @extends BaseCast */ final class IntBoolCast extends BaseCast { diff --git a/system/DataCaster/Cast/IntegerCast.php b/system/DataCaster/Cast/IntegerCast.php index f65baf83150f..9b02cf6fbbb9 100644 --- a/system/DataCaster/Cast/IntegerCast.php +++ b/system/DataCaster/Cast/IntegerCast.php @@ -14,10 +14,10 @@ namespace CodeIgniter\DataCaster\Cast; /** - * Class IntegerCast - * * (PHP) [int --> int ] --> (DB driver) --> (DB column) int * [ <-- int|string] <-- (DB driver) <-- (DB column) int + * + * @extends BaseCast */ class IntegerCast extends BaseCast { diff --git a/system/DataCaster/Cast/JsonCast.php b/system/DataCaster/Cast/JsonCast.php index 5ca10b385ba6..2a3fd5a729dd 100644 --- a/system/DataCaster/Cast/JsonCast.php +++ b/system/DataCaster/Cast/JsonCast.php @@ -18,10 +18,10 @@ use stdClass; /** - * Class JsonCast - * * (PHP) [array|stdClass --> string] --> (DB driver) --> (DB column) string * [ <-- string] <-- (DB driver) <-- (DB column) string + * + * @extends BaseCast|stdClass, string> */ class JsonCast extends BaseCast { diff --git a/system/DataCaster/Cast/TimestampCast.php b/system/DataCaster/Cast/TimestampCast.php index aa94266001ef..7dceaa7b1846 100644 --- a/system/DataCaster/Cast/TimestampCast.php +++ b/system/DataCaster/Cast/TimestampCast.php @@ -16,10 +16,10 @@ use CodeIgniter\I18n\Time; /** - * Class TimestampCast - * * (PHP) [Time --> int ] --> (DB driver) --> (DB column) int * [ <-- int|string] <-- (DB driver) <-- (DB column) int + * + * @extends BaseCast */ class TimestampCast extends BaseCast { diff --git a/system/DataCaster/Cast/URICast.php b/system/DataCaster/Cast/URICast.php index f9e2f561a2b2..f9f9487e42fb 100644 --- a/system/DataCaster/Cast/URICast.php +++ b/system/DataCaster/Cast/URICast.php @@ -16,10 +16,10 @@ use CodeIgniter\HTTP\URI; /** - * Class URICast - * * (PHP) [URI --> string] --> (DB driver) --> (DB column) string * [ <-- string] <-- (DB driver) <-- (DB column) string + * + * @extends BaseCast */ class URICast extends BaseCast { diff --git a/system/DataCaster/DataCaster.php b/system/DataCaster/DataCaster.php index e19e42bb7f0c..9d9d3c77e268 100644 --- a/system/DataCaster/DataCaster.php +++ b/system/DataCaster/DataCaster.php @@ -105,8 +105,6 @@ public function __construct( * * @param array $types [field => type] * - * @return $this - * * @internal */ public function setTypes(array $types): static diff --git a/system/DataConverter/DataConverter.php b/system/DataConverter/DataConverter.php index 4895ec4127d2..a1b5db4f0ef9 100644 --- a/system/DataConverter/DataConverter.php +++ b/system/DataConverter/DataConverter.php @@ -71,6 +71,8 @@ public function __construct( * * @param array $data DataSource data * + * @return array + * * @internal */ public function fromDataSource(array $data): array @@ -89,6 +91,8 @@ public function fromDataSource(array $data): array * * @param array $phpData PHP data * + * @return array + * * @internal */ public function toDataSource(array $phpData): array diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index f1a9428c13eb..fe2c1c371816 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -26,6 +26,8 @@ * Provides the core Query Builder methods. * Database-specific Builders might need to override * certain methods to make them work. + * + * @template TDb of BaseConnection */ class BaseBuilder { @@ -55,35 +57,35 @@ class BaseBuilder /** * QB FROM data * - * @var array + * @var list */ protected $QBFrom = []; /** * QB JOIN data * - * @var array + * @var list */ protected $QBJoin = []; /** * QB WHERE data * - * @var array + * @var list */ protected $QBWhere = []; /** * QB GROUP BY data * - * @var array + * @var list */ public $QBGroupBy = []; /** * QB HAVING data * - * @var array + * @var list */ protected $QBHaving = []; @@ -112,7 +114,7 @@ class BaseBuilder /** * QB ORDER BY data * - * @var array|string|null + * @var list|string|null */ public $QBOrderBy = []; @@ -166,10 +168,10 @@ class BaseBuilder * and is reset by resetWrite() * * @var array{ - * updateFieldsAdditional?: array, + * updateFieldsAdditional?: array, * tableIdentity?: string, - * updateFields?: array, - * constraints?: array, + * updateFields?: array, + * constraints?: array, * setQueryAsData?: string, * sql?: string, * alias?: string, @@ -183,7 +185,7 @@ class BaseBuilder /** * A reference to the database connection. * - * @var BaseConnection + * @var TDb */ protected $db; @@ -202,7 +204,7 @@ class BaseBuilder /** * ORDER BY random keyword * - * @var array + * @var list */ protected $randomKeyword = [ 'RAND()', @@ -224,7 +226,7 @@ class BaseBuilder * their values for later binding * in the Query object. * - * @var array + * @var array */ protected $binds = []; @@ -232,7 +234,7 @@ class BaseBuilder * Collects the key count for named parameters * in the Query object. * - * @var array + * @var array */ protected $bindsKeyCount = []; @@ -270,7 +272,7 @@ class BaseBuilder /** * Tables relation types * - * @var array + * @var list */ protected $joinTypes = [ 'LEFT', @@ -298,7 +300,8 @@ class BaseBuilder /** * Constructor * - * @param array|string|TableName $tableName tablename or tablenames with or without aliases + * @param array|string|TableName $tableName tablename or tablenames with or without aliases + * @param array|null $options * * Examples of $tableName: `mytable`, `jobs j`, `jobs j, users u`, `['jobs j','users u']` * @@ -306,11 +309,11 @@ class BaseBuilder */ public function __construct($tableName, ConnectionInterface $db, ?array $options = null) { - if (empty($tableName)) { + if (in_array($tableName, ['', '0', []], true)) { throw new DatabaseException('A table must be specified when creating a new Query Builder.'); } - /** @var BaseConnection $db */ + /** @var TDb $db */ $this->db = $db; if ($tableName instanceof TableName) { @@ -369,6 +372,8 @@ public function getTable(): string /** * Returns an array of bind values and their * named parameters for binding in the Query object later. + * + * @return array */ public function getBinds(): array { @@ -576,9 +581,7 @@ public function distinct(bool $val = true) /** * Generates the FROM portion of the query * - * @param array|string $from - * - * @return $this + * @param array|string|null $from */ public function from($from, bool $overwrite = false): self { @@ -608,8 +611,6 @@ public function from($from, bool $overwrite = false): self /** * @param BaseBuilder $from Expected subquery * @param string $alias Subquery alias - * - * @return $this */ public function fromSubquery(BaseBuilder $from, string $alias): self { @@ -710,8 +711,8 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu * Generates the WHERE portion of the query. * Separates multiple calls with 'AND'. * - * @param array|RawSql|string $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -726,8 +727,8 @@ public function where($key, $value = null, ?bool $escape = null) * Generates the WHERE portion of the query. * Separates multiple calls with 'OR'. * - * @param array|RawSql|string $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -742,8 +743,8 @@ public function orWhere($key, $value = null, ?bool $escape = null) * @used-by having() * @used-by orHaving() * - * @param array|RawSql|string $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -773,7 +774,7 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type $escape = $this->db->protectIdentifiers; } - $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type); + $prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type); foreach ($keyValue as $k => $v) { if ($rawSqlOnly) { @@ -782,7 +783,7 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type } elseif ($v !== null) { $op = $this->getOperatorFromWhereKey($k); - if (! empty($op)) { + if ($op !== false && $op !== []) { $k = trim($k); end($op); @@ -845,7 +846,7 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type * Generates a WHERE field IN('item', 'item') SQL query, * joined with 'AND' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -858,7 +859,7 @@ public function whereIn(?string $key = null, $values = null, ?bool $escape = nul * Generates a WHERE field IN('item', 'item') SQL query, * joined with 'OR' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -871,7 +872,7 @@ public function orWhereIn(?string $key = null, $values = null, ?bool $escape = n * Generates a WHERE field NOT IN('item', 'item') SQL query, * joined with 'AND' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -884,7 +885,7 @@ public function whereNotIn(?string $key = null, $values = null, ?bool $escape = * Generates a WHERE field NOT IN('item', 'item') SQL query, * joined with 'OR' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -897,7 +898,7 @@ public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape * Generates a HAVING field IN('item', 'item') SQL query, * joined with 'AND' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -910,7 +911,7 @@ public function havingIn(?string $key = null, $values = null, ?bool $escape = nu * Generates a HAVING field IN('item', 'item') SQL query, * joined with 'OR' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -923,7 +924,7 @@ public function orHavingIn(?string $key = null, $values = null, ?bool $escape = * Generates a HAVING field NOT IN('item', 'item') SQL query, * joined with 'AND' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder):BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -936,7 +937,7 @@ public function havingNotIn(?string $key = null, $values = null, ?bool $escape = * Generates a HAVING field NOT IN('item', 'item') SQL query, * joined with 'OR' if appropriate. * - * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery + * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery * * @return $this */ @@ -989,7 +990,7 @@ 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); + $prefix = $this->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type); $whereIn = [ 'condition' => "{$prefix}{$key}{$not} IN :{$ok}:", @@ -1005,7 +1006,7 @@ protected function _whereIn(?string $key = null, $values = null, bool $not = fal * Generates a %LIKE% portion of the query. * Separates multiple calls with 'AND'. * - * @param array|RawSql|string $field + * @param array|RawSql|string $field * * @return $this */ @@ -1018,7 +1019,7 @@ public function like($field, string $match = '', string $side = 'both', ?bool $e * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'AND'. * - * @param array|RawSql|string $field + * @param array|RawSql|string $field * * @return $this */ @@ -1031,7 +1032,7 @@ public function notLike($field, string $match = '', string $side = 'both', ?bool * Generates a %LIKE% portion of the query. * Separates multiple calls with 'OR'. * - * @param array|RawSql|string $field + * @param array|RawSql|string $field * * @return $this */ @@ -1044,7 +1045,7 @@ public function orLike($field, string $match = '', string $side = 'both', ?bool * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'OR'. * - * @param array|RawSql|string $field + * @param array|RawSql|string $field * * @return $this */ @@ -1057,7 +1058,7 @@ public function orNotLike($field, string $match = '', string $side = 'both', ?bo * Generates a %LIKE% portion of the query. * Separates multiple calls with 'AND'. * - * @param array|RawSql|string $field + * @param array|RawSql|string $field * * @return $this */ @@ -1070,7 +1071,7 @@ public function havingLike($field, string $match = '', string $side = 'both', ?b * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'AND'. * - * @param array|RawSql|string $field + * @param array|RawSql|string $field * * @return $this */ @@ -1083,7 +1084,7 @@ public function notHavingLike($field, string $match = '', string $side = 'both', * Generates a %LIKE% portion of the query. * Separates multiple calls with 'OR'. * - * @param array|RawSql|string $field + * @param array|RawSql|string $field * * @return $this */ @@ -1096,7 +1097,7 @@ 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 + * @param array|RawSql|string $field * * @return $this */ @@ -1129,7 +1130,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->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type); if ($side === 'none') { $bind = $this->setBind($field->getBindingKey(), $v, $escape); @@ -1361,7 +1362,7 @@ protected function groupStartPrepare(string $not = '', string $type = 'AND ', st $type = $this->groupGetType($type); $this->QBWhereGroupStarted = true; - $prefix = empty($this->{$clause}) ? '' : $type; + $prefix = $this->{$clause} === [] ? '' : $type; $where = [ 'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (', 'escape' => false, @@ -1408,7 +1409,7 @@ protected function groupGetType(string $type): string } /** - * @param array|string $by + * @param array|string $by * * @return $this */ @@ -1441,8 +1442,8 @@ public function groupBy($by, ?bool $escape = null) /** * Separates multiple calls with 'AND'. * - * @param array|RawSql|string $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -1454,8 +1455,8 @@ public function having($key, $value = null, ?bool $escape = null) /** * Separates multiple calls with 'OR'. * - * @param array|RawSql|string $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -1487,9 +1488,7 @@ public function orderBy(string $orderBy, string $direction = '', ?bool $escape = $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : ''; } - if ($escape === null) { - $escape = $this->db->protectIdentifiers; - } + $escape ??= $this->db->protectIdentifiers; if ($escape === false) { $qbOrderBy[] = [ @@ -1564,9 +1563,9 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string /** * Allows key/value pairs to be set for insert(), update() or replace(). * - * @param array|object|string $key Field name, or an array of field/value pairs, or an object - * @param mixed $value Field value, if $key is a single field - * @param bool|null $escape Whether to escape values + * @param array|object|string $key Field name, or an array of field/value pairs, or an object + * @param mixed $value Field value, if $key is a single field + * @param bool|null $escape Whether to escape values * * @return $this */ @@ -1595,6 +1594,8 @@ public function set($key, $value = '', ?bool $escape = null) /** * Returns the previously set() data, alternatively resetting it if needed. + * + * @return array|list> */ public function getSetData(bool $clean = false): array { @@ -1630,7 +1631,7 @@ protected function compileFinalQuery(string $sql): string $query = new Query($this->db); $query->setQuery($sql, $this->binds, false); - if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) { + if ($this->db->swapPre !== '' && $this->db->DBPrefix !== '') { $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre); } @@ -1641,7 +1642,7 @@ protected function compileFinalQuery(string $sql): string * Compiles the select statement based on the other functions called * and runs the query * - * @return false|ResultInterface + * @return false|ResultInterface|string */ public function get(?int $limit = null, int $offset = 0, bool $reset = true) { @@ -1687,7 +1688,7 @@ public function countAll(bool $reset = true) $query = $this->db->query($sql, null, false); - if (empty($query->getResult())) { + if ($query->getResult() === []) { return 0; } @@ -1713,7 +1714,7 @@ public function countAllResults(bool $reset = true) // for selecting COUNT(*) ... $orderBy = []; - if (! empty($this->QBOrderBy)) { + if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) { $orderBy = $this->QBOrderBy; $this->QBOrderBy = null; @@ -1724,7 +1725,7 @@ public function countAllResults(bool $reset = true) $this->QBLimit = false; - if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) { + if ($this->QBDistinct === true || $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"; @@ -1753,7 +1754,7 @@ public function countAllResults(bool $reset = true) $row = $result instanceof ResultInterface ? $result->getRow() : null; - if (empty($row)) { + if ($row === null) { return 0; } @@ -1763,7 +1764,7 @@ public function countAllResults(bool $reset = true) /** * Compiles the set conditions and returns the sql statement * - * @return array + * @return list */ public function getCompiledQBWhere() { @@ -1773,9 +1774,9 @@ public function getCompiledQBWhere() /** * Allows the where clause, limit and offset to be added directly * - * @param array|string $where + * @param array|RawSql|string|null $where * - * @return ResultInterface + * @return false|ResultInterface|string */ public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true) { @@ -1817,7 +1818,7 @@ public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bo */ protected function batchExecute(string $renderMethod, int $batchSize = 100) { - if (empty($this->QBSet)) { + if ($this->QBSet === []) { if ($this->db->DBDebug) { throw new DatabaseException(trim($renderMethod, '_') . '() has no data.'); } @@ -1863,14 +1864,14 @@ protected function batchExecute(string $renderMethod, int $batchSize = 100) /** * Allows a row or multiple rows to be set for batch inserts/upserts/updates * - * @param array|object $set - * @param string $alias alias for sql table + * @param array|object $set + * @param string $alias alias for sql table * * @return $this|null */ public function setData($set, ?bool $escape = null, string $alias = '') { - if (empty($set)) { + if ($set === []) { if ($this->db->DBDebug) { throw new DatabaseException('setData() has no data.'); } @@ -1946,7 +1947,7 @@ public function getCompiledUpsert() /** * Converts call to batchUpsert * - * @param array|object|null $set + * @param array|object|null $set * * @return false|int|list Number of affected rows or FALSE on failure, SQL array when testMode * @@ -1982,7 +1983,7 @@ public function upsert($set = null, ?bool $escape = null) /** * Compiles batch upsert strings and runs the queries * - * @param array|object|null $set a dataset + * @param array|object|null $set a dataset * * @return false|int|list Number of affected rows or FALSE on failure, SQL array when testMode * @@ -2069,15 +2070,15 @@ private function setAlias(string $alias): BaseBuilder /** * Sets update fields for upsert, update * - * @param list|list|string $set - * @param bool $addToDefault adds update fields to the default ones - * @param array|null $ignore ignores items in set + * @param array|string $set + * @param bool $addToDefault Adds update fields to the default ones + * @param list|null $ignore Ignores items in set * * @return $this */ public function updateFields($set, bool $addToDefault = false, ?array $ignore = null) { - if (! empty($set)) { + if (! in_array($set, [null, [], ''], true)) { if (! is_array($set)) { $set = explode(',', $set); } @@ -2113,13 +2114,13 @@ public function updateFields($set, bool $addToDefault = false, ?array $ignore = /** * Sets constraints for batch upsert, update * - * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql + * @param array|RawSql|string|null $set A string of columns, key value pairs, or RawSql * * @return $this */ public function onConstraint($set) { - if (! empty($set)) { + if (! in_array($set, [null, [], ''], true)) { if (is_string($set)) { $set = explode(',', $set); @@ -2149,8 +2150,8 @@ public function onConstraint($set) /** * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch() * - * @param BaseBuilder|RawSql $query - * @param array|string|null $columns an array or comma delimited string of columns + * @param BaseBuilder|RawSql $query + * @param list|string|null $columns an array or comma delimited string of columns */ public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder { @@ -2194,6 +2195,8 @@ public function setQueryAsData($query, ?string $alias = null, $columns = null): /** * Gets column names from a select query + * + * @return list */ protected function fieldsFromQuery(string $sql): array { @@ -2202,6 +2205,10 @@ protected function fieldsFromQuery(string $sql): array /** * Converts value array of array to array of strings + * + * @param list> $values + * + * @return list */ protected function formatValues(array $values): array { @@ -2211,7 +2218,7 @@ protected function formatValues(array $values): array /** * Compiles batch insert strings and runs the queries * - * @param array|object|null $set a dataset + * @param array|object|null $set a dataset * * @return false|int|list Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode */ @@ -2322,7 +2329,7 @@ public function getCompiledInsert(bool $reset = true) /** * Compiles an insert string and runs the query * - * @param array|object|null $set + * @param array|object|null $set * * @return BaseResult|bool|Query * @@ -2392,7 +2399,7 @@ protected function removeAlias(string $from): string */ protected function validateInsert(): bool { - if (empty($this->QBSet)) { + if ($this->QBSet === []) { if ($this->db->DBDebug) { throw new DatabaseException('You must use the "set" method to insert an entry.'); } @@ -2418,6 +2425,8 @@ protected function _insert(string $table, array $keys, array $unescapedKeys): st /** * Compiles a replace into string and runs the query * + * @param array|null $set + * * @return BaseResult|false|Query|string * * @throws DatabaseException @@ -2428,7 +2437,7 @@ public function replace(?array $set = null) $this->set($set); } - if (empty($this->QBSet)) { + if ($this->QBSet === []) { if ($this->db->DBDebug) { throw new DatabaseException('You must use the "set" method to update an entry.'); } @@ -2491,8 +2500,8 @@ public function getCompiledUpdate(bool $reset = true) /** * Compiles an update string and runs the query. * - * @param array|object|null $set - * @param array|RawSql|string|null $where + * @param array|object|null $set + * @param array|RawSql|string|null $where * * @throws DatabaseException */ @@ -2580,7 +2589,7 @@ protected function _update(string $table, array $values): string */ protected function validateUpdate(): bool { - if (empty($this->QBSet)) { + if ($this->QBSet === []) { if ($this->db->DBDebug) { throw new DatabaseException('You must use the "set" method to update an entry.'); } @@ -2594,8 +2603,8 @@ protected function validateUpdate(): bool /** * Sets data and calls batchExecute to run queries * - * @param array|object|null $set a dataset - * @param array|RawSql|string|null $constraints + * @param array|object|null $set a dataset + * @param array|RawSql|string|null $constraints * * @return false|int|list Number of rows affected or FALSE on failure, SQL array when testMode */ @@ -2729,7 +2738,7 @@ protected function _updateBatch(string $table, array $keys, array $values): stri /** * Allows key/value pairs to be set for batch updating * - * @param array|object $key + * @param array|object $key * * @return $this * @@ -2817,7 +2826,7 @@ public function getCompiledDelete(bool $reset = true): string /** * Compiles a delete string and runs the query * - * @param array|RawSql|string $where + * @param array|RawSql|string $where * * @return bool|string Returns a SQL string if in test mode. * @@ -2831,7 +2840,7 @@ public function delete($where = '', ?int $limit = null, bool $resetData = true) $this->where($where); } - if (empty($this->QBWhere)) { + if ($this->QBWhere === []) { if ($this->db->DBDebug) { throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.'); } @@ -2850,7 +2859,7 @@ public function delete($where = '', ?int $limit = null, bool $resetData = true) $this->QBLimit = $limit; } - if (! empty($this->QBLimit)) { + if ($this->QBLimit !== false && $this->QBLimit !== 0) { if (! $this->canLimitDeletes) { throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.'); } @@ -2868,8 +2877,8 @@ public function delete($where = '', ?int $limit = null, bool $resetData = true) /** * Sets data and calls batchExecute to run queries * - * @param array|object|null $set a dataset - * @param array|RawSql|null $constraints + * @param array|object|null $set a dataset + * @param array|RawSql|null $constraints * * @return false|int|list Number of rows affected or FALSE on failure, SQL array when testMode */ @@ -2905,9 +2914,9 @@ public function deleteBatch($set = null, $constraints = null, int $batchSize = 1 * * @used-by batchExecute() * - * @param string $table Protected table name - * @param list $keys QBKeys - * @param list $values QBSet + * @param string $table Protected table name + * @param list $keys QBKeys + * @param list> $values QBSet */ protected function _deleteBatch(string $table, array $keys, array $values): string { @@ -3059,7 +3068,7 @@ protected function _delete(string $table): string /** * Used to track SQL statements written with aliased tables. * - * @param array|string $table The table to inspect + * @param array|string $table The table to inspect * * @return string|null */ @@ -3109,7 +3118,7 @@ protected function compileSelect($selectOverride = false): string } else { $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT '; - if (empty($this->QBSelect)) { + if ($this->QBSelect === []) { $sql .= '*'; } else { // Cycle through the "select" portion of the query and prep each column name. @@ -3128,11 +3137,11 @@ protected function compileSelect($selectOverride = false): string } } - if (! empty($this->QBFrom)) { + if ($this->QBFrom !== []) { $sql .= "\nFROM " . $this->_fromTables(); } - if (! empty($this->QBJoin)) { + if ($this->QBJoin !== []) { $sql .= "\n" . implode("\n", $this->QBJoin); } @@ -3181,7 +3190,7 @@ protected function compileIgnore(string $statement) */ protected function compileWhereHaving(string $qbKey): string { - if (! empty($this->{$qbKey})) { + if ($this->{$qbKey} !== []) { foreach ($this->{$qbKey} as &$qbkey) { // Is this condition already compiled? if (is_string($qbkey)) { @@ -3270,7 +3279,7 @@ protected function compileWhereHaving(string $qbKey): string */ protected function compileGroupBy(): string { - if (! empty($this->QBGroupBy)) { + if ($this->QBGroupBy !== []) { foreach ($this->QBGroupBy as &$groupBy) { // Is it already compiled? if (is_string($groupBy)) { @@ -3329,9 +3338,9 @@ protected function unionInjection(string $sql): string /** * Takes an object as input and converts the class variables to array key/vals * - * @param array|object $object + * @param array|object $object * - * @return array + * @return array */ protected function objectToArray($object) { @@ -3357,9 +3366,9 @@ protected function objectToArray($object) /** * Takes an object as input and converts the class variables to array key/vals * - * @param array|object $object + * @param array|object $object * - * @return array + * @return array|list> */ protected function batchObjectToArray($object) { @@ -3420,7 +3429,7 @@ public function resetQuery() /** * Resets the query builder values. Called by the get() function * - * @param array $qbResetItems An array of fields to reset + * @param array $qbResetItems An array of fields to reset * * @return void */ @@ -3457,7 +3466,7 @@ protected function resetSelect() } // Reset QBFrom part - if (! empty($this->QBFrom)) { + if ($this->QBFrom !== []) { $this->from(array_shift($this->QBFrom), true); } } @@ -3497,7 +3506,7 @@ protected function hasOperator(string $str): bool /** * Returns the SQL string operator * - * @return array|false|string + * @return false|list|string */ protected function getOperator(string $str, bool $list = false) { @@ -3595,13 +3604,16 @@ protected function setBind(string $key, $value = null, bool $escape = true): str /** * Returns a clone of a Base Builder with reset query builder values. * - * @return $this + * @return static * * @deprecated */ protected function cleanClone() { - return (clone $this)->from([], true)->resetQuery(); + $clone = clone $this; + $clone->from([], true); + + return $clone->resetQuery(); } /** diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index c0cb7174dd52..1db7618e2971 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -25,35 +25,35 @@ use Throwable; /** - * @property-read array $aliasedTables - * @property-read string $charset - * @property-read bool $compress - * @property-read float $connectDuration - * @property-read float $connectTime - * @property-read string $database - * @property-read array $dateFormat - * @property-read string $DBCollat - * @property-read bool $DBDebug - * @property-read string $DBDriver - * @property-read string $DBPrefix - * @property-read string $DSN - * @property-read array|bool $encrypt - * @property-read array $failover - * @property-read string $hostname - * @property-read Query $lastQuery - * @property-read string $password - * @property-read bool $pConnect - * @property-read int|string $port - * @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 - * @property-read bool $transFailure - * @property-read bool $transStatus - * @property-read string $username + * @property-read list $aliasedTables + * @property-read string $charset + * @property-read bool $compress + * @property-read float $connectDuration + * @property-read float $connectTime + * @property-read string $database + * @property-read array $dateFormat + * @property-read string $DBCollat + * @property-read bool $DBDebug + * @property-read string $DBDriver + * @property-read string $DBPrefix + * @property-read string $DSN + * @property-read array|bool $encrypt + * @property-read list> $failover + * @property-read string $hostname + * @property-read Query $lastQuery + * @property-read string $password + * @property-read bool $pConnect + * @property-read int|string $port + * @property-read bool $pretend + * @property-read string $queryClass + * @property-read list $reservedIdentifiers + * @property-read bool $strictOn + * @property-read string $subdriver + * @property-read string $swapPre + * @property-read int $transDepth + * @property-read bool $transFailure + * @property-read bool $transStatus + * @property-read string $username * * @template TConnection * @template TResult @@ -177,7 +177,7 @@ abstract class BaseConnection implements ConnectionInterface /** * Encryption flag/data * - * @var array|bool + * @var array|bool */ protected $encrypt = false; @@ -202,7 +202,7 @@ abstract class BaseConnection implements ConnectionInterface /** * Settings for a failover connection. * - * @var array + * @var list> */ protected $failover = []; @@ -240,14 +240,14 @@ abstract class BaseConnection implements ConnectionInterface * * Identifiers that must NOT be escaped. * - * @var array + * @var list */ protected $reservedIdentifiers = ['*']; /** * Identifier escape character * - * @var array|string + * @var list|string */ public $escapeChar = '"'; @@ -268,15 +268,12 @@ abstract class BaseConnection implements ConnectionInterface /** * RegExp used to escape identifiers * - * @var array + * @var list */ protected $pregEscapeChar = []; /** - * Holds previously looked up data - * for performance reasons. - * - * @var array + * @var array */ public $dataCache = []; @@ -374,7 +371,7 @@ abstract class BaseConnection implements ConnectionInterface ]; /** - * Saves our connection settings. + * @param array $params */ public function __construct(array $params) { @@ -470,9 +467,7 @@ private function getBuiltinPropertyTypesMap(array $properties): array $className = static::class; $requested = array_fill_keys($properties, true); - if (! isset(self::$propertyBuiltinTypesCache[$className])) { - self::$propertyBuiltinTypesCache[$className] = []; - } + self::$propertyBuiltinTypesCache[$className] ??= []; // Fill only the properties requested by this call that are not cached yet. $missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]); @@ -566,7 +561,7 @@ public function initialize() // No connection resource? Check if there is a failover else throw an error if (! $this->connID) { // Check if there is a failover set - if (! empty($this->failover) && is_array($this->failover)) { + if (is_array($this->failover) && $this->failover !== []) { // Go over all the failovers foreach ($this->failover as $index => $failover) { $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover)); @@ -703,7 +698,7 @@ public function getConnection(?string $alias = null) */ public function getDatabase(): string { - return empty($this->database) ? '' : $this->database; + return is_string($this->database) ? $this->database : ''; } /** @@ -739,6 +734,8 @@ public function getPlatform(): string * collected during use of the Builder, and set here * so queries are built correctly. * + * @param list $aliases + * * @return $this */ public function setAliasedTables(array $aliases) @@ -781,7 +778,7 @@ abstract protected function execute(string $sql); * Should automatically handle different connections for read/write * queries if needed. * - * @param array|string|null $binds + * @param array|string|null $binds * * @return BaseResult|bool|Query * @@ -791,7 +788,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s { $queryClass = $queryClass !== '' && $queryClass !== '0' ? $queryClass : $this->queryClass; - if (empty($this->connID)) { + if ($this->connID === false) { $this->initialize(); } @@ -800,7 +797,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s $query->setQuery($sql, $binds, $setEscapeFlags); - if (! empty($this->swapPre) && ! empty($this->DBPrefix)) { + if ($this->swapPre !== '' && $this->DBPrefix !== '') { $query->swapPrefix($this->DBPrefix, $this->swapPre); } @@ -901,7 +898,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s */ public function simpleQuery(string $sql) { - if (empty($this->connID)) { + if ($this->connID === false) { $this->initialize(); } @@ -1015,7 +1012,7 @@ public function transBegin(bool $testMode = false): bool return true; } - if (empty($this->connID)) { + if ($this->connID === false) { $this->initialize(); } @@ -1111,7 +1108,7 @@ abstract protected function _transRollback(): bool; /** * Returns a non-shared new instance of the query builder for this connection. * - * @param array|string|TableName $tableName + * @param array|string|TableName $tableName * * @return BaseBuilder * @@ -1119,7 +1116,7 @@ abstract protected function _transRollback(): bool; */ public function table($tableName) { - if (empty($tableName)) { + if (in_array($tableName, ['', '0', []], true)) { throw new DatabaseException('You must set the database table to be used with your query.'); } @@ -1156,12 +1153,13 @@ public function newQuery(): BaseBuilder * }) * * @param Closure(BaseConnection): mixed $func + * @param array $options * * @return BasePreparedQuery|null */ public function prepare(Closure $func, array $options = []) { - if (empty($this->connID)) { + if ($this->connID === false) { $this->initialize(); } @@ -1242,12 +1240,12 @@ public function getConnectDuration(int $decimals = 6): string * insert the table prefix (if it exists) in the proper position, and escape only * the correct identifiers. * - * @param array|int|string|TableName $item - * @param bool $prefixSingle Prefix a table name with no segments? - * @param bool $protectIdentifiers Protect table or column names? - * @param bool $fieldExists Supplied $item contains a column name? + * @param array|int|string|TableName $item + * @param bool $prefixSingle Prefix a table name with no segments? + * @param bool $protectIdentifiers Protect table or column names? + * @param bool $fieldExists Supplied $item contains a column name? * - * @return ($item is array ? array : string) + * @return ($item is array ? array : string) */ public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $protectIdentifiers = null, bool $fieldExists = true) { @@ -1317,7 +1315,7 @@ public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $pro // In some cases, especially 'from', we end up running through // protect_identifiers twice. This algorithm won't work when // it contains the escapeChar so strip it out. - $item = trim($item, $this->escapeChar); + $item = $this->trimEscapeChar($item); // Is there a table prefix? If not, no need to insert it if ($this->DBPrefix !== '') { @@ -1338,17 +1336,22 @@ public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $pro return $item . $alias; } + private function trimEscapeChar(string $item): string + { + return trim($item, is_array($this->escapeChar) ? implode('', $this->escapeChar) : $this->escapeChar); + } + private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string { - $parts = explode('.', $item); + $parts = array_map($this->trimEscapeChar(...), explode('.', $item)); + $item = implode('.', $parts); // Does the first segment of the exploded item match // one of the aliases previously identified? If so, // we have nothing more to do other than escape the item // - // NOTE: The ! empty() condition prevents this method - // from breaking when QB isn't enabled. - if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) { + // $aliasedTables is empty when QB isn't enabled. + if ($this->aliasedTables !== [] && in_array($parts[0], $this->aliasedTables, true)) { if ($protectIdentifiers) { foreach ($parts as $key => $val) { if (! in_array($val, $this->reservedIdentifiers, true)) { @@ -1449,13 +1452,13 @@ private function escapeTableName(TableName $tableName): string * * This function escapes column and table names * - * @param array|string $item + * @param array|string $item * - * @return ($item is array ? array : string) + * @return ($item is array ? array : string) */ public function escapeIdentifiers($item) { - if ($this->escapeChar === '' || empty($item) || in_array($item, $this->reservedIdentifiers, true)) { + if ($this->escapeChar === '' || in_array($item, ['', '0', []], true) || in_array($item, $this->reservedIdentifiers, true)) { return $item; } @@ -1535,7 +1538,7 @@ abstract public function affectedRows(): int; * * @param mixed $str * - * @return ($str is array ? array : float|int|string) + * @return ($str is array ? array : float|int|string) */ public function escape($str) { @@ -1639,7 +1642,7 @@ protected function _escapeString(string $str): string * This function enables you to call PHP database functions that are not natively included * in CodeIgniter, in a platform independent manner. * - * @param array ...$params + * @param mixed ...$params * * @throws DatabaseException */ @@ -1683,7 +1686,7 @@ protected function getDriverFunctionPrefix(): string */ public function listTables(bool $constrainByPrefix = false) { - if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) { + if (($this->dataCache['table_names'] ?? []) !== []) { $tables = $constrainByPrefix ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names']) : $this->dataCache['table_names']; @@ -1737,7 +1740,7 @@ public function tableExists(string $tableName, bool $cached = true): bool $tableExists = $this->query($sql)->getResultArray() !== []; // if cache has been built already - if (! empty($this->dataCache['table_names'])) { + if (($this->dataCache['table_names'] ?? []) !== []) { $key = array_search( strtolower($tableName), array_map(strtolower(...), $this->dataCache['table_names']), @@ -1772,7 +1775,7 @@ public function getFieldNames($tableName) return $this->dataCache['field_names'][$table]; } - if (empty($this->connID)) { + if ($this->connID === false) { $this->initialize(); } @@ -1848,21 +1851,18 @@ public function getForeignKeyData(string $table) /** * Converts array of arrays generated by _foreignKeyData() to array of objects * - * @return array + * @param array, + * foreign_table_name: string, + * foreign_column_name: list, + * on_delete: string, + * on_update: string|null, + * match: string|null, + * }> $data * - * array[ - * {constraint_name} => - * stdClass[ - * 'constraint_name' => string, - * 'table_name' => string, - * 'column_name' => string[], - * 'foreign_table_name' => string, - * 'foreign_column_name' => string[], - * 'on_delete' => string, - * 'on_update' => string, - * 'match' => string - * ] - * ] + * @return array */ protected function foreignKeyDataToObjects(array $data) { @@ -1871,10 +1871,7 @@ protected function foreignKeyDataToObjects(array $data) foreach ($data as $row) { $name = $row['constraint_name']; - // for sqlite generate name - if ($name === null) { - $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign'; - } + $name ??= $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign'; $obj = new stdClass(); $obj->constraint_name = $name; diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index 540e9c15161c..723b6b8f12e6 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -23,6 +23,7 @@ * @template TConnection * @template TStatement * @template TResult + * @template TDb of BaseConnection * * @implements PreparedQueryInterface */ @@ -47,7 +48,7 @@ abstract class BasePreparedQuery implements PreparedQueryInterface * * @var string */ - protected $errorString; + protected $errorString = ''; /** * Holds the prepared query object @@ -60,7 +61,7 @@ abstract class BasePreparedQuery implements PreparedQueryInterface /** * A reference to the db connection to use. * - * @var BaseConnection + * @var TDb */ protected $db; @@ -69,15 +70,6 @@ public function __construct(BaseConnection $db) $this->db = $db; } - /** - * Prepares the query against the database, and saves the connection - * info necessary to execute the query later. - * - * NOTE: This version is based on SQL code. Child classes should - * override this method. - * - * @return $this - */ public function prepare(string $sql, array $options = [], string $queryClass = Query::class) { // We only support positional placeholders (?), so convert @@ -90,7 +82,7 @@ public function prepare(string $sql, array $options = [], string $queryClass = Q $query->setQuery($sql); - if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) { + if ($this->db->swapPre !== '' && $this->db->DBPrefix !== '') { $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre); } @@ -100,18 +92,13 @@ public function prepare(string $sql, array $options = [], string $queryClass = Q } /** - * The database-dependent portion of the prepare statement. + * @param array $options Passed to the connection's prepare statement. Only the SQLSRV driver uses it. * * @return $this */ abstract public function _prepare(string $sql, array $options = []); /** - * Takes a new set of data and runs it against the currently - * prepared query. Upon success, will return a Results object. - * - * @return bool|ResultInterface - * * @throws DatabaseException */ public function execute(...$data) @@ -185,7 +172,7 @@ public function execute(...$data) } /** - * The database dependant version of the execute method. + * @param list $data */ abstract public function _execute(array $data): bool; @@ -196,11 +183,6 @@ abstract public function _execute(array $data): bool; */ abstract public function _getResult(); - /** - * Explicitly closes the prepared statement. - * - * @throws BadMethodCallException - */ public function close(): bool { if (! isset($this->statement)) { @@ -214,14 +196,8 @@ public function close(): bool } } - /** - * The database-dependent version of the close method. - */ abstract protected function _close(): bool; - /** - * Returns the SQL that has been prepared. - */ public function getQueryString(): string { if (! $this->query instanceof QueryInterface) { @@ -236,20 +212,14 @@ public function getQueryString(): string */ public function hasError(): bool { - return ! empty($this->errorString); + return $this->errorString !== ''; } - /** - * Returns the error code created while executing this statement. - */ public function getErrorCode(): int { return $this->errorCode; } - /** - * Returns the error message created while executing this statement. - */ public function getErrorMessage(): string { return $this->errorString; diff --git a/system/Database/BaseResult.php b/system/Database/BaseResult.php index 5bede241a59d..5855eb064c86 100644 --- a/system/Database/BaseResult.php +++ b/system/Database/BaseResult.php @@ -25,42 +25,32 @@ abstract class BaseResult implements ResultInterface { /** - * Connection ID - * * @var TConnection */ public $connID; /** - * Result ID - * * @var false|TResult */ public $resultID; /** - * Result Array - * * @var list */ public $resultArray = []; /** - * Result Object - * * @var list */ public $resultObject = []; /** - * Custom Result Object - * - * @var array + * @var array> */ public $customResultObject = []; /** - * Current Row index + * Current row index * * @var int */ @@ -74,15 +64,11 @@ abstract class BaseResult implements ResultInterface protected $numRows; /** - * Row data - * - * @var array|null + * @var array|null */ public $rowData; /** - * Constructor - * * @param TConnection $connID * @param TResult $resultID */ @@ -92,13 +78,6 @@ public function __construct(&$connID, &$resultID) $this->resultID = $resultID; } - /** - * Retrieve the results of the query. Typically an array of - * individual data rows, which can be either an 'array', an - * 'object', or a custom class name. - * - * @param string $type The row type. Either 'array', 'object', or a class name to use - */ public function getResult(string $type = 'object'): array { if ($type === 'array') { @@ -112,13 +91,6 @@ public function getResult(string $type = 'object'): array return $this->getCustomResultObject($type); } - /** - * Returns the results as an array of custom objects. - * - * @param class-string $className - * - * @return array - */ public function getCustomResultObject(string $className) { if (isset($this->customResultObject[$className])) { @@ -165,11 +137,6 @@ public function getCustomResultObject(string $className) return $this->customResultObject[$className]; } - /** - * Returns the results as an array of arrays. - * - * If no results, an empty array is returned. - */ public function getResultArray(): array { if ($this->resultArray !== []) { @@ -202,13 +169,6 @@ public function getResultArray(): array return $this->resultArray; } - /** - * Returns the results as an array of objects. - * - * If no results, an empty array is returned. - * - * @return list - */ public function getResultObject(): array { if ($this->resultObject !== []) { @@ -245,19 +205,6 @@ public function getResultObject(): array return $this->resultObject; } - /** - * Wrapper object to return a row as either an array, an object, or - * a custom class. - * - * If the row doesn't exist, returns null. - * - * @template T of object - * - * @param int|string $n The index of the results to return, or column name. - * @param 'array'|'object'|class-string $type The type of result object. 'array', 'object' or class name. - * - * @return ($n is string ? float|int|string|null : ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : T|null))) - */ public function getRow($n = 0, string $type = 'object') { // $n is a column name. @@ -268,7 +215,7 @@ public function getRow($n = 0, string $type = 'object') } // array_key_exists() instead of isset() to allow for NULL values - if (empty($this->rowData) || ! array_key_exists($n, $this->rowData)) { + if ($this->rowData === null || ! array_key_exists($n, $this->rowData)) { return null; } @@ -286,25 +233,13 @@ public function getRow($n = 0, string $type = 'object') return $this->getCustomRowObject($n, $type); } - /** - * Returns a row as a custom class instance. - * - * If the row doesn't exist, returns null. - * - * @template T of object - * - * @param int $n The index of the results to return. - * @param class-string $className - * - * @return T|null - */ public function getCustomRowObject(int $n, string $className) { if (! isset($this->customResultObject[$className])) { $this->getCustomResultObject($className); } - if (empty($this->customResultObject[$className])) { + if (($this->customResultObject[$className] ?? []) === []) { return null; } @@ -315,13 +250,6 @@ public function getCustomRowObject(int $n, string $className) return $this->customResultObject[$className][$this->currentRow] ?? null; } - /** - * Returns a single row from the results as an array. - * - * If row doesn't exist, returns null. - * - * @return array|null - */ public function getRowArray(int $n = 0) { $result = $this->getResultArray(); @@ -336,13 +264,6 @@ public function getRowArray(int $n = 0) return $result[$this->currentRow] ?? null; } - /** - * Returns a single row from the results as an object. - * - * If row doesn't exist, returns null. - * - * @return object|stdClass|null - */ public function getRowObject(int $n = 0) { $result = $this->getResultObject(); @@ -357,14 +278,6 @@ public function getRowObject(int $n = 0) return $result[$this->currentRow] ?? null; } - /** - * Assigns an item into a particular column slot. - * - * @param array|string $key - * @param array|object|stdClass|null $value - * - * @return void - */ public function setRow($key, $value = null) { // We cache the row data for subsequent uses @@ -385,11 +298,6 @@ public function setRow($key, $value = null) } } - /** - * Returns the "first" row of the current results. - * - * @return array|object|null - */ public function getFirstRow(string $type = 'object') { $result = $this->getResult($type); @@ -397,11 +305,6 @@ public function getFirstRow(string $type = 'object') return ($result === []) ? null : $result[0]; } - /** - * Returns the "last" row of the current results. - * - * @return array|object|null - */ public function getLastRow(string $type = 'object') { $result = $this->getResult($type); @@ -409,11 +312,6 @@ public function getLastRow(string $type = 'object') return ($result === []) ? null : $result[count($result) - 1]; } - /** - * Returns the "next" row of the current results. - * - * @return array|object|null - */ public function getNextRow(string $type = 'object') { $result = $this->getResult($type); @@ -424,11 +322,6 @@ public function getNextRow(string $type = 'object') return isset($result[$this->currentRow + 1]) ? $result[++$this->currentRow] : null; } - /** - * Returns the "previous" row of the current results. - * - * @return array|object|null - */ public function getPreviousRow(string $type = 'object') { $result = $this->getResult($type); @@ -443,11 +336,6 @@ public function getPreviousRow(string $type = 'object') return $result[$this->currentRow] ?? null; } - /** - * Returns an unbuffered row and move the pointer to the next row. - * - * @return array|object|null - */ public function getUnbufferedRow(string $type = 'object') { if ($type === 'array') { @@ -486,43 +374,12 @@ private function isValidResultId(): bool return is_resource($this->resultID) || is_object($this->resultID); } - /** - * Gets the number of fields in the result set. - */ - abstract public function getFieldCount(): int; - - /** - * Generates an array of column names in the result set. - */ - abstract public function getFieldNames(): array; - - /** - * Generates an array of objects representing field meta-data. - */ - abstract public function getFieldData(): array; - - /** - * Frees the current result. - * - * @return void - */ - abstract public function freeResult(); - - /** - * Moves the internal pointer to the desired offset. This is called - * internally before fetching results to make sure the result set - * starts at zero. - * - * @return bool - */ - abstract public function dataSeek(int $n = 0); - /** * Returns the result set as an array. * * Overridden by driver classes. * - * @return array|false|null + * @return array|false|null */ abstract protected function fetchAssoc(); diff --git a/system/Database/BaseUtils.php b/system/Database/BaseUtils.php index 938c28364444..442fdd2a766b 100644 --- a/system/Database/BaseUtils.php +++ b/system/Database/BaseUtils.php @@ -16,14 +16,12 @@ use CodeIgniter\Database\Exceptions\DatabaseException; /** - * Class BaseUtils + * @template TDb of BaseConnection */ abstract class BaseUtils { /** - * Database object - * - * @var object + * @var TDb */ protected $db; @@ -49,7 +47,7 @@ abstract class BaseUtils protected $repairTable = false; /** - * Class constructor + * @param TDb $db */ public function __construct(ConnectionInterface $db) { @@ -59,7 +57,7 @@ public function __construct(ConnectionInterface $db) /** * List databases * - * @return array|bool + * @return bool|list * * @throws DatabaseException */ @@ -152,7 +150,7 @@ public function optimizeDatabase() $res = $res->getResultArray(); // Postgre & SQLite3 returns empty array - if (empty($res)) { + if ($res === []) { $key = $tableName; } else { $res = current($res); @@ -229,13 +227,13 @@ public function getCSVFromResult(ResultInterface $query, string $delim = ',', st /** * Generate XML data from a query result object + * + * @param array $params */ public function getXMLFromResult(ResultInterface $query, array $params = []): string { foreach (['root' => 'root', 'element' => 'element', 'newline' => "\n", 'tab' => "\t"] as $key => $val) { - if (! isset($params[$key])) { - $params[$key] = $val; - } + $params[$key] ??= $val; } $root = $params['root']; @@ -264,9 +262,9 @@ public function getXMLFromResult(ResultInterface $query, array $params = []): st /** * Database Backup * - * @param array|string $params + * @param array|string $params * - * @return false|never|string + * @return false|string * * @throws DatabaseException */ @@ -287,7 +285,7 @@ public function backup($params = []) 'foreign_key_checks' => true, ]; - if (! empty($params)) { + if ($params !== []) { foreach (array_keys($prefs) as $key) { if (isset($params[$key])) { $prefs[$key] = $params[$key]; @@ -295,7 +293,7 @@ public function backup($params = []) } } - if (empty($prefs['tables'])) { + if ($prefs['tables'] === [] || $prefs['tables'] === '') { $prefs['tables'] = $this->db->listTables(); } @@ -320,9 +318,9 @@ public function backup($params = []) } /** - * Platform dependent version of the backup function. + * @param array|null $prefs * - * @return false|never|string + * @return false|string */ abstract public function _backup(?array $prefs = null); } diff --git a/system/Database/Config.php b/system/Database/Config.php index 413d0b3b5de8..250af22acd62 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -26,7 +26,7 @@ class Config extends BaseConfig * Cache for instance of any connections that * have been requested as a "shared" instance. * - * @var array + * @var array */ protected static $instances = []; @@ -41,9 +41,9 @@ class Config extends BaseConfig /** * Returns the database connection * - * @param array|BaseConnection|non-empty-string|null $group The name of the connection group to use, - * or an array of configuration settings. - * @param bool $getShared Whether to return a shared instance of the connection. + * @param array|BaseConnection|non-empty-string|null $group The name of the connection group to use, + * or an array of configuration settings. + * @param bool $getShared Whether to return a shared instance of the connection. * * @return BaseConnection */ @@ -60,9 +60,7 @@ public static function connect($group = null, bool $getShared = true) } else { $dbConfig = config(DbConfig::class); - if ($group === null) { - $group = (ENVIRONMENT === 'testing') ? 'tests' : $dbConfig->defaultGroup; - } + $group ??= (ENVIRONMENT === 'testing') ? 'tests' : $dbConfig->defaultGroup; assert(is_string($group)); @@ -90,6 +88,8 @@ public static function connect($group = null, bool $getShared = true) /** * Returns an array of all db connections currently made. + * + * @return array */ public static function getConnections(): array { @@ -100,7 +100,7 @@ public static function getConnections(): array * Loads and returns an instance of the Forge for the specified * database group, and loads the group if it hasn't been loaded yet. * - * @param array|ConnectionInterface|string|null $group + * @param array|ConnectionInterface|string|null $group * * @return Forge */ @@ -114,7 +114,7 @@ public static function forge($group = null) /** * Returns a new instance of the Database Utilities class. * - * @param array|string|null $group + * @param array|string|null $group * * @return BaseUtils */ diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 15fd63e1c7aa..0474736b5cd2 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -100,7 +100,7 @@ public function getVersion(): string; * Should automatically handle different connections for read/write * queries if needed. * - * @param array|string|null $binds + * @param array|string|null $binds * * @return BaseResult|bool|Query */ @@ -118,7 +118,7 @@ public function simpleQuery(string $sql); /** * Returns an instance of the query builder for this connection. * - * @param array|string $tableName Table name. + * @param array|string $tableName Table name. * * @return BaseBuilder Builder. */ @@ -139,7 +139,7 @@ public function getLastQuery(); * * @param mixed $str * - * @return ($str is array ? array : float|int|string) + * @return ($str is array ? array : float|int|string) */ public function escape($str); @@ -147,7 +147,7 @@ public function escape($str); * Allows for custom calls to the database engine that are not * supported through our database layer. * - * @param array ...$params + * @param mixed ...$params * * @return mixed */ diff --git a/system/Database/Database.php b/system/Database/Database.php index 9598242f01bb..a6f0e9cb73ef 100644 --- a/system/Database/Database.php +++ b/system/Database/Database.php @@ -31,13 +31,15 @@ class Database * Helps to keep track of all open connections for performance * monitoring, logging, etc. * - * @var array + * @var array */ protected $connections = []; /** * Parses the connection binds and creates a Database Connection instance. * + * @param array $params + * * @return BaseConnection * * @throws InvalidArgumentException @@ -48,15 +50,15 @@ public function load(array $params = [], string $alias = '') throw new InvalidArgumentException('You must supply the parameter: alias.'); } - if (! empty($params['DSN']) && str_contains($params['DSN'], '://')) { + if (($params['DSN'] ?? '') !== '' && str_contains($params['DSN'], '://')) { $params = $this->parseDSN($params); } - if (empty($params['DBDriver'])) { + if (($params['DBDriver'] ?? '') === '') { throw new InvalidArgumentException('You have not selected a database type to connect to.'); } - assert($this->checkDbExtension($params['DBDriver'])); + $this->checkDbExtension($params['DBDriver']); $this->connections[$alias] = $this->initDriver($params['DBDriver'], 'Connection', $params); @@ -94,6 +96,10 @@ public function loadUtils(ConnectionInterface $db): BaseUtils /** * Parses universal DSN string * + * @param array $params + * + * @return array + * * @throws InvalidArgumentException */ protected function parseDSN(array $params): array @@ -132,9 +138,9 @@ protected function parseDSN(array $params): array /** * Creates a database object. * - * @param string $driver Driver name. FQCN can be used. - * @param string $class 'Connection'|'Forge'|'Utils' - * @param array|ConnectionInterface $argument The constructor parameter or DB connection + * @param string $driver Driver name. FQCN can be used. + * @param string $class 'Connection'|'Forge'|'Utils' + * @param array|ConnectionInterface $argument The constructor parameter or DB connection * * @return BaseConnection|BaseUtils|Forge */ @@ -148,15 +154,17 @@ protected function initDriver(string $driver, string $class, $argument): object } /** - * Check the PHP database extension is loaded. + * Check if the PHP database extension is loaded. * * @param string $driver DB driver or FQCN for custom driver + * + * @throws ConfigException if the driver is invalid + * @throws CriticalError if the required PHP extension is not loaded */ - private function checkDbExtension(string $driver): bool + private function checkDbExtension(string $driver): void { if (str_contains($driver, '\\')) { - // Cannot check a fully qualified classname for a custom driver. - return true; + return; // Cannot check a fully qualified classname for a custom driver. } $extensionMap = [ @@ -168,21 +176,17 @@ private function checkDbExtension(string $driver): bool 'OCI8' => 'oci8', ]; - $extension = $extensionMap[$driver] ?? ''; - - if ($extension === '') { - $message = 'Invalid DBDriver name: "' . $driver . '"'; - - throw new ConfigException($message); - } + $extension = $extensionMap[$driver] + ?? throw new ConfigException(sprintf('Invalid DBDriver name: "%s".', $driver)); if (extension_loaded($extension)) { - return true; + return; } - $message = 'The required PHP extension "' . $extension . '" is not loaded.' - . ' Install and enable it to use "' . $driver . '" driver.'; - - throw new CriticalError($message); + throw new CriticalError(sprintf( + 'The required PHP extension "%s" is not loaded. Install and enable it to use "%s" driver.', + $extension, + $driver, + )); } } diff --git a/system/Database/Forge.php b/system/Database/Forge.php index 9291683acb5f..7eefba484bc1 100644 --- a/system/Database/Forge.php +++ b/system/Database/Forge.php @@ -21,13 +21,15 @@ /** * The Forge class transforms migrations to executable * SQL statements. + * + * @template TDb of BaseConnection */ class Forge { /** * The active database connection. * - * @var BaseConnection + * @var TDb */ protected $db; @@ -48,7 +50,7 @@ class Forge /** * List of unique keys. * - * @var array + * @var list */ protected $uniqueKeys = []; @@ -62,7 +64,7 @@ class Forge /** * List of foreign keys. * - * @var array + * @var list, referenceTable: string, referenceField: list, onDelete: string, onUpdate: string, fkName: string}> */ protected $foreignKeys = []; @@ -144,7 +146,7 @@ class Forge /** * UNSIGNED support * - * @var array|bool + * @var array|bool */ protected $unsigned = true; @@ -181,12 +183,14 @@ class Forge /** * Foreign Key Allowed Actions * - * @var array + * @var list */ protected $fkAllowActions = ['CASCADE', 'SET NULL', 'NO ACTION', 'RESTRICT', 'SET DEFAULT']; /** * Constructor. + * + * @param TDb $db */ public function __construct(BaseConnection $db) { @@ -246,7 +250,7 @@ public function createDatabase(string $dbName, bool $ifNotExists = false): bool // @codeCoverageIgnoreEnd } - if (! empty($this->db->dataCache['db_names'])) { + if (($this->db->dataCache['db_names'] ?? []) !== []) { $this->db->dataCache['db_names'][] = $dbName; } @@ -303,7 +307,7 @@ public function dropDatabase(string $dbName): bool return false; } - if (! empty($this->db->dataCache['db_names'])) { + if (($this->db->dataCache['db_names'] ?? []) !== []) { $key = array_search( strtolower($dbName), array_map(strtolower(...), $this->db->dataCache['db_names']), @@ -320,7 +324,7 @@ public function dropDatabase(string $dbName): bool /** * Add Key * - * @param array|string $key + * @param list|string $key * * @return Forge */ @@ -342,7 +346,7 @@ public function addKey($key, bool $primary = false, bool $unique = false, string /** * Add Primary Key * - * @param array|string $key + * @param list|string $key * * @return Forge */ @@ -354,7 +358,7 @@ public function addPrimaryKey($key, string $keyName = '') /** * Add Unique Key * - * @param array|string $key + * @param list|string $key * * @return Forge */ @@ -366,7 +370,7 @@ public function addUniqueKey($key, string $keyName = '') /** * Add Field * - * @param array|string $fields Field array or Field string + * @param array|string>|string $fields Field array or Field string * * @return Forge */ @@ -540,7 +544,7 @@ public function dropForeignKey(string $table, string $foreignName) } /** - * @param array $attributes Table attributes + * @param array $attributes Table attributes * * @return bool * @@ -586,7 +590,7 @@ public function createTable(string $table, bool $ifNotExists = false, array $att } /** - * @param array $attributes Table attributes + * @param array $attributes Table attributes * * @return string SQL string * @@ -622,6 +626,9 @@ protected function _createTable(string $table, bool $ifNotExists, array $attribu ); } + /** + * @param array $attributes + */ protected function _createTableAttributes(array $attributes): string { $sql = ''; @@ -664,7 +671,7 @@ public function dropTable(string $tableName, bool $ifExists = false, bool $casca $this->db->enableForeignKeyChecks(); - if ($query && ! empty($this->db->dataCache['table_names'])) { + if ($query && ($this->db->dataCache['table_names'] ?? []) !== []) { $key = array_search( strtolower($this->db->DBPrefix . $tableName), array_map(strtolower(...), $this->db->dataCache['table_names']), @@ -726,7 +733,7 @@ public function renameTable(string $tableName, string $newTableName) $this->db->escapeIdentifiers($this->db->DBPrefix . $newTableName), )); - if ($result && ! empty($this->db->dataCache['table_names'])) { + if ($result && ($this->db->dataCache['table_names'] ?? []) !== []) { $key = array_search( strtolower($this->db->DBPrefix . $tableName), array_map(strtolower(...), $this->db->dataCache['table_names']), @@ -742,7 +749,7 @@ public function renameTable(string $tableName, string $newTableName) } /** - * @param array|string $fields Field array or Field string + * @param array|string>|string $fields Field array or Field string * * @throws DatabaseException */ @@ -800,7 +807,7 @@ public function dropColumn(string $table, $columnNames) } /** - * @param array|string $fields Field array or Field string + * @param array|string>|string $fields Field array or Field string * * @throws DatabaseException */ @@ -842,9 +849,9 @@ public function modifyColumn(string $table, $fields): bool } /** - * @param 'ADD'|'CHANGE'|'DROP' $alterType - * @param array|string $processedFields Processed column definitions - * or column names to DROP + * @param 'ADD'|'CHANGE'|'DROP' $alterType + * @param list>|list|string $processedFields Processed column definitions + * or column names to DROP * * @return ($alterType is 'DROP' ? string : false|list|null) */ @@ -880,6 +887,8 @@ protected function _alterTable(string $alterType, string $table, $processedField /** * Returns $processedFields array from $this->fields data. + * + * @return list> */ protected function _processFields(bool $createTable = false): array { @@ -894,7 +903,7 @@ protected function _processFields(bool $createTable = false): array $attributes = array_change_key_case($attributes, CASE_UPPER); - if ($createTable && empty($attributes['TYPE'])) { + if ($createTable && ($attributes['TYPE'] ?? '') === '') { continue; } @@ -933,7 +942,7 @@ protected function _processFields(bool $createTable = false): array $nullString = ' ' . $this->null; if ($attributes['NULL'] === true) { - $field['null'] = empty($this->null) ? '' : $nullString; + $field['null'] = $this->null === '' ? '' : $nullString; } elseif ($attributes['NULL'] === $nullString) { $field['null'] = $nullString; } elseif ($attributes['NULL'] === '') { @@ -952,7 +961,7 @@ protected function _processFields(bool $createTable = false): array $field['comment'] = $this->db->escape($attributes['COMMENT']); } - if (isset($attributes['TYPE']) && ! empty($attributes['CONSTRAINT'])) { + if (isset($attributes['TYPE'], $attributes['CONSTRAINT']) && ! in_array($attributes['CONSTRAINT'], ['', '0', 0, []], true)) { if (is_array($attributes['CONSTRAINT'])) { $attributes['CONSTRAINT'] = $this->db->escape($attributes['CONSTRAINT']); $attributes['CONSTRAINT'] = implode(',', $attributes['CONSTRAINT']); @@ -969,6 +978,8 @@ protected function _processFields(bool $createTable = false): array /** * Converts $processedField array to field definition string. + * + * @param array $processedField */ protected function _processColumn(array $processedField): string { @@ -984,6 +995,8 @@ protected function _processColumn(array $processedField): string /** * Performs a data type mapping between different databases. * + * @param array $attributes + * * @return void */ protected function _attributeType(array &$attributes) @@ -1001,11 +1014,14 @@ protected function _attributeType(array &$attributes) * - array(TYPE => UTYPE) will change $field['type'], * from TYPE to UTYPE in case of a match * + * @param array $attributes + * @param array $field + * * @return void */ protected function _attributeUnsigned(array &$attributes, array &$field) { - if (empty($attributes['UNSIGNED']) || $attributes['UNSIGNED'] !== true) { + if (($attributes['UNSIGNED'] ?? false) !== true) { return; } @@ -1034,6 +1050,9 @@ protected function _attributeUnsigned(array &$attributes, array &$field) } /** + * @param array $attributes + * @param array $field + * * @return void */ protected function _attributeDefault(array &$attributes, array &$field) @@ -1044,11 +1063,11 @@ protected function _attributeDefault(array &$attributes, array &$field) if (array_key_exists('DEFAULT', $attributes)) { if ($attributes['DEFAULT'] === null) { - $field['default'] = empty($this->null) ? '' : $this->default . $this->null; + $field['default'] = $this->null === '' ? '' : $this->default . $this->null; // Override the NULL attribute if that's our default $attributes['NULL'] = true; - $field['null'] = empty($this->null) ? '' : ' ' . $this->null; + $field['null'] = $this->null === '' ? '' : ' ' . $this->null; } elseif ($attributes['DEFAULT'] instanceof RawSql) { $field['default'] = $this->default . $attributes['DEFAULT']; } else { @@ -1058,21 +1077,27 @@ protected function _attributeDefault(array &$attributes, array &$field) } /** + * @param array $attributes + * @param array $field + * * @return void */ protected function _attributeUnique(array &$attributes, array &$field) { - if (! empty($attributes['UNIQUE']) && $attributes['UNIQUE'] === true) { + if (($attributes['UNIQUE'] ?? false) === true) { $field['unique'] = ' UNIQUE'; } } /** + * @param array $attributes + * @param array $field + * * @return void */ protected function _attributeAutoIncrement(array &$attributes, array &$field) { - if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true + if (($attributes['AUTO_INCREMENT'] ?? false) === true && str_contains(strtolower($field['type']), 'int') ) { $field['auto_increment'] = ' AUTO_INCREMENT'; @@ -1160,6 +1185,8 @@ public function processIndexes(string $table): bool * Generates SQL to add indexes * * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE + * + * @return list */ protected function _processIndexes(string $table, bool $asQuery = false): array { @@ -1206,6 +1233,8 @@ protected function _processIndexes(string $table, bool $asQuery = false): array * Generates SQL to add foreign keys * * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE + * + * @return list */ protected function _processForeignKeys(string $table, bool $asQuery = false): array { diff --git a/system/Database/MigrationRunner.php b/system/Database/MigrationRunner.php index df5ee049f0b7..0b3459ba076e 100644 --- a/system/Database/MigrationRunner.php +++ b/system/Database/MigrationRunner.php @@ -90,7 +90,7 @@ class MigrationRunner /** * used to return messages for CLI. * - * @var array + * @var list */ protected $cliMessages = []; @@ -116,7 +116,7 @@ class MigrationRunner /** * The full path to locate migration files. * - * @var string + * @var string|null */ protected $path; @@ -139,7 +139,7 @@ class MigrationRunner * default DB group so that it creates the `migrations` table in the default * DB group. Therefore, passing $db is for testing purposes only. * - * @param array|ConnectionInterface|string|null $db DB group. For testing purposes only. + * @param array|ConnectionInterface|string|null $db DB group. For testing purposes only. * * @throws ConfigException */ @@ -440,7 +440,7 @@ public function force(string $path, string $namespace, ?string $group = null) /** * Retrieves list of available migration scripts * - * @return array List of all located migrations by their UID + * @return array List of all located migrations by their UID */ public function findMigrations(): array { @@ -465,13 +465,15 @@ public function findMigrations(): array /** * Retrieves a list of available migration scripts for one namespace + * + * @return list */ public function findNamespaceMigrations(string $namespace): array { $migrations = []; $locator = service('locator', true); - if (! empty($this->path)) { + if ($this->path !== null && $this->path !== '') { helper('filesystem'); $dir = rtrim($this->path, DIRECTORY_SEPARATOR) . '/'; $files = get_filenames($dir, true, false, false); @@ -480,7 +482,7 @@ public function findNamespaceMigrations(string $namespace): array } foreach ($files as $file) { - $file = empty($this->path) ? $file : $this->path . str_replace($this->path, '', $file); + $file = $this->path === null || $this->path === '' ? $file : $this->path . str_replace($this->path, '', $file); if ($migration = $this->migrationFromFile($file, $namespace)) { $migrations[] = $migration; @@ -610,6 +612,8 @@ public function getObjectUid($object): string /** * Retrieves messages formatted for CLI output + * + * @return list */ public function getCliMessages(): array { @@ -693,6 +697,8 @@ protected function removeHistory($history) /** * Grabs the full migration history from the database for a group + * + * @return list */ public function getHistory(string $group = 'default'): array { @@ -712,13 +718,15 @@ public function getHistory(string $group = 'default'): array $query = $builder->orderBy('id', 'ASC')->get(); - return empty($query) ? [] : $query->getResultObject(); + return $query === false ? [] : $query->getResultObject(); } /** * Returns the migration history for a single batch. * * @param string $order + * + * @return list */ public function getBatchHistory(int $batch, $order = 'asc'): array { @@ -729,11 +737,13 @@ public function getBatchHistory(int $batch, $order = 'asc'): array ->orderBy('id', $order) ->get(); - return empty($query) ? [] : $query->getResultObject(); + return $query === false ? [] : $query->getResultObject(); } /** * Returns all the batches from the database history in order + * + * @return list */ public function getBatches(): array { diff --git a/system/Database/MySQLi/Builder.php b/system/Database/MySQLi/Builder.php index bba90421e9e4..1d923d1982e6 100644 --- a/system/Database/MySQLi/Builder.php +++ b/system/Database/MySQLi/Builder.php @@ -19,6 +19,8 @@ /** * Builder for MySQLi + * + * @extends BaseBuilder */ class Builder extends BaseBuilder { diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 116acb7eaf9e..10e56e944dd4 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\LogicException; use mysqli; use mysqli_result; @@ -51,7 +50,7 @@ class Connection extends BaseConnection /** * Identifier escape character * - * @var string + * @var list|string */ public $escapeChar = '`'; @@ -108,7 +107,7 @@ public function connect(bool $persistent = false) $socket = $this->hostname; } else { $hostname = $persistent ? 'p:' . $this->hostname : $this->hostname; - $port = empty($this->port) ? null : $this->port; + $port = $this->port === '' ? null : $this->port; $socket = ''; } @@ -147,19 +146,19 @@ public function connect(bool $persistent = false) if (is_array($this->encrypt)) { $ssl = []; - if (! empty($this->encrypt['ssl_key'])) { + if (($this->encrypt['ssl_key'] ?? '') !== '') { $ssl['key'] = $this->encrypt['ssl_key']; } - if (! empty($this->encrypt['ssl_cert'])) { + if (($this->encrypt['ssl_cert'] ?? '') !== '') { $ssl['cert'] = $this->encrypt['ssl_cert']; } - if (! empty($this->encrypt['ssl_ca'])) { + if (($this->encrypt['ssl_ca'] ?? '') !== '') { $ssl['ca'] = $this->encrypt['ssl_ca']; } - if (! empty($this->encrypt['ssl_capath'])) { + if (($this->encrypt['ssl_capath'] ?? '') !== '') { $ssl['capath'] = $this->encrypt['ssl_capath']; } - if (! empty($this->encrypt['ssl_cipher'])) { + if (($this->encrypt['ssl_cipher'] ?? '') !== '') { $ssl['cipher'] = $this->encrypt['ssl_cipher']; } @@ -234,11 +233,6 @@ public function connect(bool $persistent = false) return false; } - /** - * Close the database connection. - * - * @return void - */ protected function _close() { $this->connID->close(); @@ -253,7 +247,7 @@ public function setDatabase(string $databaseName): bool $databaseName = $this->database; } - if (empty($this->connID)) { + if ($this->connID === false) { $this->initialize(); } @@ -275,7 +269,7 @@ public function getVersion(): string return $this->dataCache['version']; } - if (empty($this->mysqli)) { + if (! $this->mysqli instanceof mysqli) { $this->initialize(); } @@ -379,10 +373,7 @@ public function escapeLikeStringDirect($str) } /** - * Generates the SQL for listing tables in a platform-dependent manner. * Uses escapeLikeStringDirect(). - * - * @param string|null $tableName If $tableName is provided will return only this table if exists. */ protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string { @@ -399,11 +390,6 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n return $sql; } - /** - * Generates a platform-specific query string so that the column names can be fetched. - * - * @param string|TableName $table - */ protected function _listColumns($table = ''): string { $tableName = $this->protectIdentifiers( @@ -417,10 +403,6 @@ protected function _listColumns($table = ''): string } /** - * Returns an array of objects with field data - * - * @return list - * * @throws DatabaseException */ protected function _fieldData(string $table): array @@ -449,10 +431,6 @@ protected function _fieldData(string $table): array } /** - * Returns an array of objects with index data - * - * @return array - * * @throws DatabaseException * @throws LogicException */ @@ -473,7 +451,7 @@ protected function _indexData(string $table): array $keys = []; foreach ($indexes as $index) { - if (empty($keys[$index['Key_name']])) { + if (! isset($keys[$index['Key_name']])) { $keys[$index['Key_name']] = new stdClass(); $keys[$index['Key_name']]->name = $index['Key_name']; @@ -481,7 +459,7 @@ protected function _indexData(string $table): array $type = 'PRIMARY'; } elseif ($index['Index_type'] === 'FULLTEXT') { $type = 'FULLTEXT'; - } elseif ($index['Non_unique']) { + } elseif ((bool) $index['Non_unique']) { $type = $index['Index_type'] === 'SPATIAL' ? 'SPATIAL' : 'INDEX'; } else { $type = 'UNIQUE'; @@ -497,10 +475,6 @@ protected function _indexData(string $table): array } /** - * Returns an array of objects with Foreign key data - * - * @return array - * * @throws DatabaseException */ protected function _foreignKeyData(string $table): array @@ -577,7 +551,7 @@ protected function _enableForeignKeyChecks() */ public function error(): array { - if (! empty($this->mysqli->connect_errno)) { + if ($this->mysqli instanceof mysqli && $this->mysqli->connect_errno !== 0) { return [ 'code' => $this->mysqli->connect_errno, 'message' => $this->mysqli->connect_error, @@ -598,25 +572,16 @@ public function insertID(): int return $this->connID->insert_id; } - /** - * Begin Transaction - */ protected function _transBegin(): bool { return $this->connID->begin_transaction(); } - /** - * Commit Transaction - */ protected function _transCommit(): bool { return $this->connID->commit(); } - /** - * Rollback Transaction - */ protected function _transRollback(): bool { return $this->connID->rollback(); diff --git a/system/Database/MySQLi/Forge.php b/system/Database/MySQLi/Forge.php index 9482f7e2329a..0cbe85a3d470 100644 --- a/system/Database/MySQLi/Forge.php +++ b/system/Database/MySQLi/Forge.php @@ -17,13 +17,13 @@ /** * Forge for MySQLi + * + * @extends BaseForge */ class Forge extends BaseForge { /** * CREATE DATABASE statement - * - * @var string */ protected $createDatabaseStr = 'CREATE DATABASE %s CHARACTER SET %s COLLATE %s'; @@ -36,8 +36,6 @@ class Forge extends BaseForge /** * DROP CONSTRAINT statement - * - * @var string */ protected $dropConstraintStr = 'ALTER TABLE %s DROP FOREIGN KEY %s'; @@ -54,7 +52,7 @@ class Forge extends BaseForge /** * UNSIGNED support * - * @var array + * @var list */ protected $_unsigned = [ 'TINYINT', @@ -74,7 +72,7 @@ class Forge extends BaseForge /** * Table Options list which required to be quoted * - * @var array + * @var list */ protected $_quoted_table_options = [ 'COMMENT', @@ -98,7 +96,7 @@ class Forge extends BaseForge /** * CREATE TABLE attributes * - * @param array $attributes Associative array of table attributes + * @param array $attributes Associative array of table attributes */ protected function _createTableAttributes(array $attributes): string { @@ -127,16 +125,6 @@ protected function _createTableAttributes(array $attributes): string return $sql; } - /** - * ALTER TABLE - * - * @param string $alterType ALTER type - * @param string $table Table name - * @param array|string $processedFields Processed column definitions - * or column names to DROP - * - * @return ($alterType is 'DROP' ? string : list) - */ protected function _alterTable(string $alterType, string $table, $processedFields) { if ($alterType === 'DROP') { @@ -152,7 +140,7 @@ protected function _alterTable(string $alterType, string $table, $processedField if ($alterType === 'ADD') { $processedFields[$i]['_literal'] = "\n\tADD "; } else { - $processedFields[$i]['_literal'] = empty($field['new_name']) ? "\n\tMODIFY " : "\n\tCHANGE "; + $processedFields[$i]['_literal'] = ($field['new_name'] ?? '') === '' ? "\n\tMODIFY " : "\n\tCHANGE "; } $processedFields[$i] = $processedFields[$i]['_literal'] . $this->_processColumn($processedFields[$i]); @@ -169,27 +157,22 @@ protected function _processColumn(array $processedField): string { $extraClause = isset($processedField['after']) ? ' AFTER ' . $this->db->escapeIdentifiers($processedField['after']) : ''; - if (empty($extraClause) && isset($processedField['first']) && $processedField['first'] === true) { + if ($extraClause === '' && isset($processedField['first']) && $processedField['first'] === true) { $extraClause = ' FIRST'; } return $this->db->escapeIdentifiers($processedField['name']) - . (empty($processedField['new_name']) ? '' : ' ' . $this->db->escapeIdentifiers($processedField['new_name'])) + . (($processedField['new_name'] ?? '') === '' ? '' : ' ' . $this->db->escapeIdentifiers($processedField['new_name'])) . ' ' . $processedField['type'] . $processedField['length'] . $processedField['unsigned'] . $processedField['null'] . $processedField['default'] . $processedField['auto_increment'] . $processedField['unique'] - . (empty($processedField['comment']) ? '' : ' COMMENT ' . $processedField['comment']) + . (($processedField['comment'] ?? '') === '' ? '' : ' COMMENT ' . $processedField['comment']) . $extraClause; } - /** - * Generates SQL to add indexes - * - * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE - */ protected function _processIndexes(string $table, bool $asQuery = false): array { $sqls = ['']; diff --git a/system/Database/MySQLi/PreparedQuery.php b/system/Database/MySQLi/PreparedQuery.php index 05ef5f151b7b..4f40a358386c 100644 --- a/system/Database/MySQLi/PreparedQuery.php +++ b/system/Database/MySQLi/PreparedQuery.php @@ -24,20 +24,10 @@ /** * Prepared query for MySQLi * - * @extends BasePreparedQuery + * @extends BasePreparedQuery */ class PreparedQuery extends BasePreparedQuery { - /** - * Prepares the query against the database, and saves the connection - * info necessary to execute the query later. - * - * NOTE: This version is based on SQL code. Child classes should - * override this method. - * - * @param array $options Passed to the connection's prepare statement. - * Unused in the MySQLi driver. - */ public function _prepare(string $sql, array $options = []): PreparedQuery { // Mysqli driver doesn't like statements @@ -56,10 +46,6 @@ public function _prepare(string $sql, array $options = []): PreparedQuery return $this; } - /** - * Takes a new set of data and runs it against the currently - * prepared query. Upon success, will return a Results object. - */ public function _execute(array $data): bool { if (! isset($this->statement)) { @@ -104,8 +90,6 @@ public function _execute(array $data): bool } /** - * Returns the result object for the prepared query or false on failure. - * * @return false|mysqli_result */ public function _getResult() @@ -113,9 +97,6 @@ public function _getResult() return $this->statement->get_result(); } - /** - * Deallocate prepared statements. - */ protected function _close(): bool { return $this->statement->close(); diff --git a/system/Database/MySQLi/Result.php b/system/Database/MySQLi/Result.php index 873bbd08b9d9..956a4e0a705c 100644 --- a/system/Database/MySQLi/Result.php +++ b/system/Database/MySQLi/Result.php @@ -26,17 +26,11 @@ */ class Result extends BaseResult { - /** - * Gets the number of fields in the result set. - */ public function getFieldCount(): int { return $this->resultID->field_count; } - /** - * Generates an array of column names in the result set. - */ public function getFieldNames(): array { $fieldNames = []; @@ -49,9 +43,6 @@ public function getFieldNames(): array return $fieldNames; } - /** - * Generates an array of objects representing field meta-data. - */ public function getFieldData(): array { static $dataTypes = [ @@ -103,11 +94,6 @@ public function getFieldData(): array return $retVal; } - /** - * Frees the current result. - * - * @return void - */ public function freeResult() { if (is_object($this->resultID)) { @@ -116,49 +102,27 @@ public function freeResult() } } - /** - * Moves the internal pointer to the desired offset. This is called - * internally before fetching results to make sure the result set - * starts at zero. - * - * @return bool - */ public function dataSeek(int $n = 0) { return $this->resultID->data_seek($n); } - /** - * Returns the result set as an array. - * - * Overridden by driver classes. - * - * @return array|false|null - */ protected function fetchAssoc() { return $this->resultID->fetch_assoc(); } - /** - * Returns the result set as an object. - * - * Overridden by child classes. - * - * @return Entity|false|object|stdClass - */ - protected function fetchObject(string $className = 'stdClass') + protected function fetchObject(string $className = stdClass::class) { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + $data = $this->fetchAssoc(); + + return in_array($data, [null, false, []], true) ? false : (new $className())->injectRawData($data); } return $this->resultID->fetch_object($className); } - /** - * Returns the number of rows in the resultID (i.e., mysqli_result object) - */ public function getNumRows(): int { if (! is_int($this->numRows)) { diff --git a/system/Database/MySQLi/Utils.php b/system/Database/MySQLi/Utils.php index be68367361fd..16f0d0f98ec7 100644 --- a/system/Database/MySQLi/Utils.php +++ b/system/Database/MySQLi/Utils.php @@ -18,26 +18,22 @@ /** * Utils for MySQLi + * + * @extends BaseUtils */ class Utils extends BaseUtils { /** - * List databases statement - * - * @var string + * @var bool|string */ protected $listDatabases = 'SHOW DATABASES'; /** - * OPTIMIZE TABLE statement - * - * @var string + * @var bool|string */ protected $optimizeTable = 'OPTIMIZE TABLE %s'; /** - * Platform dependent version of the backup function. - * * @return never */ public function _backup(?array $prefs = null) diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index ff5b1289c34a..abdaf10ae4a1 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -16,9 +16,12 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\RawSql; +use stdClass; /** * Builder for OCI8 + * + * @extends BaseBuilder */ class Builder extends BaseBuilder { @@ -32,7 +35,7 @@ class Builder extends BaseBuilder /** * ORDER BY random keyword * - * @var array + * @var list */ protected $randomKeyword = [ '"DBMS_RANDOM"."RANDOM"', @@ -48,13 +51,6 @@ class Builder extends BaseBuilder */ protected $countString = 'SELECT COUNT(1) '; - /** - * A reference to the database connection. - * - * @var Connection - */ - protected $db; - /** * Generates a platform-specific insert string from the supplied data. */ @@ -150,7 +146,7 @@ protected function _truncate(string $table): string /** * Compiles a delete string and runs the query * - * @param array|RawSql|string $where + * @param array|RawSql|string $where * * @return bool|string * @@ -206,7 +202,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string $offset = (int) ($offsetIgnore === false ? $this->QBOffset : 0); // OFFSET-FETCH can be used only with the ORDER BY clause - if (empty($this->QBOrderBy)) { + if (! is_array($this->QBOrderBy) || $this->QBOrderBy === []) { $sql .= ' ORDER BY 1'; } @@ -287,14 +283,21 @@ protected function _updateBatch(string $table, array $keys, array $values): stri if (isset($this->QBOptions['setQueryAsData'])) { $data = $this->QBOptions['setQueryAsData']; } else { + $updateFields = $this->QBOptions['updateFields'] ?? []; + $lengths = $this->longestValues($keys, $values); + $data = implode( " UNION ALL\n", array_map( - static fn ($value): string => 'SELECT ' . implode(', ', array_map( - static fn ($key, $index): string => $index . ' ' . $key, + fn (array $value): string => sprintf('SELECT %s FROM DUAL', implode(', ', array_map( + fn (string $key, float|int|string $index): string => sprintf( + '%s %s', + $this->cast((string) $index, $this->getSourceField($table, $key, $updateFields), $lengths[$key]), + $key, + ), $keys, $value, - )) . ' FROM DUAL', + ))), $values, ), ) . "\n"; @@ -316,7 +319,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri if ($sql === '') { $constraints = $this->QBOptions['constraints'] ?? []; - if (empty($constraints)) { + if ($constraints === []) { $fieldNames = array_map(static fn ($columnName): string => trim($columnName, '"'), $keys); $uniqueIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames): bool { @@ -334,7 +337,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; } - if (empty($constraints)) { + if ($constraints === []) { if ($this->db->DBDebug) { throw new DatabaseException('No constraint found for upsert.'); } @@ -471,14 +474,21 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri if (isset($this->QBOptions['setQueryAsData'])) { $data = $this->QBOptions['setQueryAsData']; } else { + $constraints = $this->QBOptions['constraints'] ?? []; + $lengths = $this->longestValues($keys, $values); + $data = implode( " FROM DUAL UNION ALL\n", array_map( - static fn ($value): string => 'SELECT ' . implode(', ', array_map( - static fn ($key, $index): string => $index . ' ' . $key, + fn (array $value): string => sprintf('SELECT %s', implode(', ', array_map( + fn (string $key, float|int|string $index): string => sprintf( + '%s %s', + $this->cast((string) $index, $this->getSourceField($table, $key, $constraints), $lengths[$key]), + $key, + ), $keys, $value, - )), + ))), $values, ), ) . " FROM DUAL\n"; @@ -494,4 +504,82 @@ protected function fieldsFromQuery(string $sql): array { return $this->db->query('SELECT * FROM (' . $sql . ') "_u_" WHERE ROWNUM = 1')->getFieldNames(); } + + /** + * Returns the longest escaped value per key across the batch. + * + * @param list $keys + * @param list> $values + * + * @return array + */ + private function longestValues(array $keys, array $values): array + { + $lengths = []; + + foreach ($keys as $i => $key) { + $lengths[$key] = max(array_map(static fn (array $row): int => mb_strlen((string) $row[$i]), $values)); + } + + return $lengths; + } + + /** + * Returns the field shared by every destination column fed from the source key, or null when their types differ. + * + * @param array $map Destination column to source key. + */ + private function getSourceField(string $table, string $key, array $map): ?stdClass + { + $columns = array_filter(array_keys($map, $key, true), is_string(...)); + + if ($columns === []) { + return $this->getField($table, $key); + } + + $fields = array_map(fn (string $column): ?stdClass => $this->getField($table, $column), $columns); + $types = array_unique(array_map(static fn (?stdClass $field): string => $field instanceof stdClass ? $field->type . '(' . $field->max_length . ')' : '', $fields)); + + return count($types) === 1 ? reset($fields) : null; + } + + /** + * Returns the literal converted to the field type, or unchanged when the field is unknown or a LOB. + * + * @param int $length Longest value for the key in the batch. + */ + private function cast(string $value, ?stdClass $field, int $length): string + { + if (! $field instanceof stdClass) { + return $value; + } + + return match ($field->type) { + // Every row gets the same fixed width, wide enough for the longest value, so the UNION ALL stays CHAR and nothing is truncated. + 'CHAR', 'NCHAR' => sprintf('CAST(%s AS %s(%d))', $value, $field->type, max((int) $field->max_length, $length)), + 'VARCHAR2' => sprintf('TO_CHAR(%s)', $value), + 'NVARCHAR2' => sprintf('TO_NCHAR(%s)', $value), + 'RAW', 'LONG RAW', 'CLOB', 'NCLOB', 'BLOB', 'BFILE', 'LONG' => $value, + default => sprintf('CAST(%s AS %s)', $value, $field->type), + }; + } + + /** + * @param string $table Protected table name. + * @param string $fieldName Field name. May be protected. + */ + private function getField(string $table, string $fieldName): ?stdClass + { + $fieldName = trim($fieldName, $this->db->escapeChar); + + if (! isset($this->QBOptions['fields'][$table])) { + $this->QBOptions['fields'][$table] = []; + + foreach ($this->db->getFieldData($table) as $field) { + $this->QBOptions['fields'][$table][$field->name] = $field; + } + } + + return $this->QBOptions['fields'][$table][$fieldName] ?? null; + } } diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index dc884588a251..0d700ef095cf 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -37,7 +37,7 @@ class Connection extends BaseConnection /** * Identifier escape character * - * @var string + * @var list|string */ public $escapeChar = '"'; @@ -46,13 +46,16 @@ class Connection extends BaseConnection * * Identifiers that must NOT be escaped. * - * @var array + * @var list */ protected $reservedIdentifiers = [ '*', 'rownum', ]; + /** + * @var array + */ protected $validDSNs = [ // TNS 'tns' => '/^\(DESCRIPTION=(\(.+\)){2,}\)$/', @@ -79,6 +82,8 @@ class Connection extends BaseConnection * * Used by storedProcedure() to prevent execute() from * re-setting the statement ID. + * + * @var bool */ protected $resetStmtId = true; @@ -150,11 +155,6 @@ public function connect(bool $persistent = false) : $func($this->username, $this->password, $this->DSN, $this->charset); } - /** - * Close the database connection. - * - * @return void - */ protected function _close() { if (is_resource($this->cursorId)) { @@ -278,11 +278,6 @@ public function affectedRows(): int return oci_num_rows($this->stmtId); } - /** - * Generates the SQL for listing tables in a platform-dependent manner. - * - * @param string|null $tableName If $tableName is provided will return only this table if exists. - */ protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string { $sql = 'SELECT "TABLE_NAME" FROM "USER_TABLES"'; @@ -299,11 +294,6 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n return $sql; } - /** - * Generates a platform-specific query string so that the column names can be fetched. - * - * @param string|TableName $table - */ protected function _listColumns($table = ''): string { if ($table instanceof TableName) { @@ -323,10 +313,6 @@ protected function _listColumns($table = ''): string } /** - * Returns an array of objects with field data - * - * @return list - * * @throws DatabaseException */ protected function _fieldData(string $table): array @@ -380,10 +366,6 @@ private function normalizeDefault(?string $default): ?string } /** - * Returns an array of objects with index data - * - * @return array - * * @throws DatabaseException */ protected function _indexData(string $table): array @@ -429,10 +411,6 @@ protected function _indexData(string $table): array } /** - * Returns an array of objects with Foreign key data - * - * @return array - * * @throws DatabaseException */ protected function _foreignKeyData(string $table): array @@ -539,14 +517,8 @@ public function getCursor() /** * Executes a stored procedure * - * @param string $procedureName procedure name to execute - * @param array $params params array keys - * KEY OPTIONAL NOTES - * name no the name of the parameter should be in : format - * value no the value of the parameter. If this is an OUT or IN OUT parameter, - * this should be a reference to a variable - * type yes the type of the parameter - * length yes the max size of the parameter + * @param string $procedureName Procedure name to execute + * @param list $params `name` must be in `:` format. `value` is bound by reference. * * @return bool|Query|Result */ @@ -560,7 +532,7 @@ public function storedProcedure(string $procedureName, array $params) $sql = sprintf( 'BEGIN %s (' . substr(str_repeat(',%s', count($params)), 1) . '); END;', $procedureName, - ...array_map(static fn ($row) => $row['name'], $params), + ...array_map(static fn (array $row): string => $row['name'], $params), ); $this->resetStmtId = false; @@ -575,7 +547,7 @@ public function storedProcedure(string $procedureName, array $params) /** * Bind parameters * - * @param array $params + * @param list $params * * @return void */ @@ -628,7 +600,7 @@ public function error(): array public function insertID(): int { - if (empty($this->lastInsertedTableName)) { + if ($this->lastInsertedTableName === null || $this->lastInsertedTableName === '') { return 0; } @@ -729,9 +701,6 @@ protected function buildDSN() $this->DSN = ''; } - /** - * Begin Transaction - */ protected function _transBegin(): bool { $this->commitMode = OCI_NO_AUTO_COMMIT; @@ -739,9 +708,6 @@ protected function _transBegin(): bool return true; } - /** - * Commit Transaction - */ protected function _transCommit(): bool { $this->commitMode = OCI_COMMIT_ON_SUCCESS; @@ -749,9 +715,6 @@ protected function _transCommit(): bool return oci_commit($this->connID); } - /** - * Rollback Transaction - */ protected function _transRollback(): bool { $this->commitMode = OCI_COMMIT_ON_SUCCESS; @@ -764,7 +727,7 @@ protected function _transRollback(): bool */ public function getDatabase(): string { - if (! empty($this->database)) { + if (is_string($this->database) && $this->database !== '') { return $this->database; } diff --git a/system/Database/OCI8/Forge.php b/system/Database/OCI8/Forge.php index 8f71169115ce..8b7b7ddfa3ce 100644 --- a/system/Database/OCI8/Forge.php +++ b/system/Database/OCI8/Forge.php @@ -17,6 +17,8 @@ /** * Forge for OCI8 + * + * @extends BaseForge */ class Forge extends BaseForge { @@ -29,38 +31,28 @@ class Forge extends BaseForge /** * CREATE DATABASE statement - * - * @var false */ protected $createDatabaseStr = false; /** * CREATE TABLE IF statement * - * @var false - * * @deprecated This is no longer used. */ protected $createTableIfStr = false; /** * DROP TABLE IF EXISTS statement - * - * @var false */ protected $dropTableIfStr = false; /** * DROP DATABASE statement - * - * @var false */ protected $dropDatabaseStr = false; /** * UNSIGNED support - * - * @var array|bool */ protected $unsigned = false; @@ -73,35 +65,19 @@ class Forge extends BaseForge /** * RENAME TABLE statement - * - * @var string */ protected $renameTableStr = 'ALTER TABLE %s RENAME TO %s'; /** * DROP CONSTRAINT statement - * - * @var string */ protected $dropConstraintStr = 'ALTER TABLE %s DROP CONSTRAINT %s'; /** * Foreign Key Allowed Actions - * - * @var array */ protected $fkAllowActions = ['CASCADE', 'SET NULL', 'NO ACTION']; - /** - * ALTER TABLE - * - * @param string $alterType ALTER type - * @param string $table Table name - * @param array|string $processedFields Processed column definitions - * or column names to DROP - * - * @return ($alterType is 'DROP' ? string : list) - */ protected function _alterTable(string $alterType, string $table, $processedFields) { $sql = 'ALTER TABLE ' . $this->db->escapeIdentifiers($table); @@ -149,13 +125,13 @@ protected function _alterTable(string $alterType, string $table, $processedField } else { $processedFields[$i]['_literal'] = "\n\t" . $this->_processColumn($processedFields[$i]); - if (! empty($processedFields[$i]['comment'])) { + if (($processedFields[$i]['comment'] ?? '') !== '') { $sqls[] = 'COMMENT ON COLUMN ' . $this->db->escapeIdentifiers($table) . '.' . $this->db->escapeIdentifiers($processedFields[$i]['name']) . ' IS ' . $processedFields[$i]['comment']; } - if ($alterType === 'MODIFY' && ! empty($processedFields[$i]['new_name'])) { + if ($alterType === 'MODIFY' && ($processedFields[$i]['new_name'] ?? '') !== '') { $sqls[] = $sql . ' RENAME COLUMN ' . $this->db->escapeIdentifiers($processedFields[$i]['name']) . ' TO ' . $this->db->escapeIdentifiers($processedFields[$i]['new_name']); } @@ -182,7 +158,7 @@ protected function _alterTable(string $alterType, string $table, $processedField */ protected function _attributeAutoIncrement(array &$attributes, array &$field) { - if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true + if (($attributes['AUTO_INCREMENT'] ?? false) === true && str_contains(strtolower($field['type']), 'number') && version_compare($this->db->getVersion(), '12.1', '>=') ) { diff --git a/system/Database/OCI8/PreparedQuery.php b/system/Database/OCI8/PreparedQuery.php index 390ed938fc1c..38c24ff26b99 100644 --- a/system/Database/OCI8/PreparedQuery.php +++ b/system/Database/OCI8/PreparedQuery.php @@ -21,32 +21,15 @@ /** * Prepared query for OCI8 * - * @extends BasePreparedQuery + * @extends BasePreparedQuery */ class PreparedQuery extends BasePreparedQuery { - /** - * A reference to the db connection to use. - * - * @var Connection - */ - protected $db; - /** * Latest inserted table name. */ private ?string $lastInsertTableName = null; - /** - * Prepares the query against the database, and saves the connection - * info necessary to execute the query later. - * - * NOTE: This version is based on SQL code. Child classes should - * override this method. - * - * @param array $options Passed to the connection's prepare statement. - * Unused in the OCI8 driver. - */ public function _prepare(string $sql, array $options = []): PreparedQuery { if (! $this->statement = oci_parse($this->db->connID, $this->parameterize($sql))) { @@ -64,10 +47,6 @@ public function _prepare(string $sql, array $options = []): PreparedQuery return $this; } - /** - * Takes a new set of data and runs it against the currently - * prepared query. Upon success, will return a Results object. - */ public function _execute(array $data): bool { if (! isset($this->statement)) { @@ -100,8 +79,6 @@ public function _execute(array $data): bool } /** - * Returns the statement resource for the prepared query or false when preparing failed. - * * @return resource|null */ public function _getResult() @@ -109,9 +86,6 @@ public function _getResult() return $this->statement; } - /** - * Deallocate prepared statements. - */ protected function _close(): bool { return oci_free_statement($this->statement); diff --git a/system/Database/OCI8/Result.php b/system/Database/OCI8/Result.php index 1049847b7af0..6882bff00f45 100644 --- a/system/Database/OCI8/Result.php +++ b/system/Database/OCI8/Result.php @@ -24,25 +24,16 @@ */ class Result extends BaseResult { - /** - * Gets the number of fields in the result set. - */ public function getFieldCount(): int { return oci_num_fields($this->resultID); } - /** - * Generates an array of column names in the result set. - */ public function getFieldNames(): array { return array_map(fn ($fieldIndex): false|string => oci_field_name($this->resultID, $fieldIndex), range(1, $this->getFieldCount())); } - /** - * Generates an array of objects representing field meta-data. - */ public function getFieldData(): array { return array_map(fn ($fieldIndex) => (object) [ @@ -52,11 +43,6 @@ public function getFieldData(): array ], range(1, $this->getFieldCount())); } - /** - * Frees the current result. - * - * @return void - */ public function freeResult() { if (is_resource($this->resultID)) { @@ -66,10 +52,6 @@ public function freeResult() } /** - * Moves the internal pointer to the desired offset. This is called - * internally before fetching results to make sure the result set - * starts at zero. - * * @return false */ public function dataSeek(int $n = 0) @@ -79,25 +61,14 @@ public function dataSeek(int $n = 0) } /** - * Returns the result set as an array. - * - * Overridden by driver classes. - * - * @return array|false + * @return array|false */ protected function fetchAssoc() { return oci_fetch_assoc($this->resultID); } - /** - * Returns the result set as an object. - * - * Overridden by child classes. - * - * @return Entity|false|object|stdClass - */ - protected function fetchObject(string $className = 'stdClass') + protected function fetchObject(string $className = stdClass::class) { $row = oci_fetch_object($this->resultID); diff --git a/system/Database/OCI8/Utils.php b/system/Database/OCI8/Utils.php index c6e97963facf..4995446076fb 100644 --- a/system/Database/OCI8/Utils.php +++ b/system/Database/OCI8/Utils.php @@ -18,19 +18,17 @@ /** * Utils for OCI8 + * + * @extends BaseUtils */ class Utils extends BaseUtils { /** - * List databases statement - * - * @var string + * @var bool|string */ protected $listDatabases = 'SELECT TABLESPACE_NAME FROM USER_TABLESPACES'; /** - * Platform dependent version of the backup function. - * * @return never */ public function _backup(?array $prefs = null) diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index c3a673558ec8..5367ae2a0fc3 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -22,13 +22,15 @@ /** * Builder for Postgre + * + * @extends BaseBuilder */ class Builder extends BaseBuilder { /** * ORDER BY random keyword * - * @var array + * @var list */ protected $randomKeyword = [ 'RANDOM()', @@ -54,8 +56,8 @@ protected function compileIgnore(string $statement) { $sql = parent::compileIgnore($statement); - if (! empty($sql)) { - $sql = ' ' . trim($sql); + if ($sql !== '') { + return ' ' . trim($sql); } return $sql; @@ -138,7 +140,7 @@ public function decrement(string $column, int $value = 1) * we simply do a DELETE and an INSERT on the first key/value * combo, assuming that it's either the primary key or a unique key. * - * @param array|null $set An associative array of insert values + * @param array|null $set An associative array of insert values * * @return BaseResult|false|Query|string * @@ -171,9 +173,9 @@ public function replace(?array $set = null) $builder = $this->db->table($table); $exists = $builder->where($key, $value, true)->get()->getFirstRow(); - if (empty($exists) && $this->testMode) { + if ($exists === null && $this->testMode) { $result = $this->getCompiledInsert(); - } elseif (empty($exists)) { + } elseif ($exists === null) { $result = $builder->insert($set); } elseif ($this->testMode) { $result = $this->where($key, $value, true)->getCompiledUpdate(); @@ -225,7 +227,7 @@ protected function _insertBatch(string $table, array $keys, array $values): stri /** * Compiles a delete string and runs the query * - * @param array|RawSql|string $where + * @param array|RawSql|string $where * * @return bool|string * @@ -233,7 +235,7 @@ protected function _insertBatch(string $table, array $keys, array $values): stri */ public function delete($where = '', ?int $limit = null, bool $resetData = true) { - if ($limit !== null && $limit !== 0 || ! empty($this->QBLimit)) { + if ($limit !== null && $limit !== 0 || ($this->QBLimit !== false && $this->QBLimit !== 0)) { throw new DatabaseException('PostgreSQL does not allow LIMITs on DELETE queries.'); } @@ -255,7 +257,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string */ protected function _update(string $table, array $values): string { - if (! empty($this->QBLimit)) { + if ($this->QBLimit !== false && $this->QBLimit !== 0) { throw new DatabaseException('Postgres does not support LIMITs with UPDATE queries.'); } @@ -370,7 +372,7 @@ protected function _updateBatch(string $table, array $keys, array $values): stri $sql .= 'WHERE ' . implode( ' AND ', array_map( - static function ($key, $value) use ($table, $alias, $that): string|RawSql { + static function ($key, $value) use ($table, $alias, $that): RawSql|string { if ($value instanceof RawSql && is_string($key)) { return $table . '.' . $key . ' = ' . $value; } @@ -393,14 +395,20 @@ static function ($key, $value) use ($table, $alias, $that): string|RawSql { if (isset($this->QBOptions['setQueryAsData'])) { $data = $this->QBOptions['setQueryAsData']; } else { + $updateFields = $this->QBOptions['updateFields'] ?? []; + $data = implode( " UNION ALL\n", array_map( - static fn ($value): string => 'SELECT ' . implode(', ', array_map( - static fn ($key, $index): string => $index . ' ' . $key, + fn (array $value): string => sprintf('SELECT %s', implode(', ', array_map( + fn (string $key, float|int|string $index): string => sprintf( + '%s %s', + $this->castValue((string) $index, $this->getSourceType($table, $key, $updateFields)), + $key, + ), $keys, $value, - )), + ))), $values, ), ) . "\n"; @@ -409,6 +417,38 @@ static function ($key, $value) use ($table, $alias, $that): string|RawSql { return str_replace('{:_table_:}', $data, $sql); } + /** + * Returns the type shared by every destination column fed from the source key, or null when they differ. + * + * @param array $map Destination column to source key. + */ + private function getSourceType(string $table, string $key, array $map): ?string + { + $columns = array_filter(array_keys($map, $key, true), is_string(...)); + + if ($columns === []) { + return $this->getFieldType($table, $key); + } + + $types = array_unique(array_map(fn (string $column): ?string => $this->getFieldType($table, $column), $columns)); + + return count($types) === 1 ? reset($types) : null; + } + + /** + * Returns the literal cast to the column type, except a numeric literal for a numeric column, which stays as is so a non-integral value never rounds into a match. + */ + private function castValue(string $value, ?string $type): string + { + $isNumericType = in_array(strtolower((string) $type), ['smallint', 'integer', 'bigint', 'numeric', 'real', 'double precision'], true); + + if ($isNumericType && is_numeric($value)) { + return $value; + } + + return $this->cast($value, $type); + } + /** * Returns cast expression. * @@ -435,11 +475,15 @@ private function getFieldType(string $table, string $fieldName): ?string foreach ($this->db->getFieldData($table) as $field) { $type = $field->type; - // If `character` (or `char`) lacks a specifier, it is equivalent - // to `character(1)`. - // See https://www.postgresql.org/docs/current/datatype-character.html - if ($field->type === 'character') { - $type = $field->type . '(' . $field->max_length . ')'; + // information_schema reports enum, domain and array columns as `USER-DEFINED` or `ARRAY`, which are not castable names. + if ($type === 'USER-DEFINED' || $type === 'ARRAY') { + continue; + } + + // `character` without a length is `character(1)` and `character(n)` truncates on cast, + // while `bpchar` keeps the full value. + if ($type === 'character') { + $type = 'bpchar'; } $this->QBOptions['fieldTypes'][$table][$field->name] = $type; @@ -464,7 +508,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $constraints = $this->QBOptions['constraints'] ?? []; - if (empty($constraints)) { + if ($constraints === []) { $allIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames): bool { $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields); @@ -479,7 +523,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; } - if (empty($constraints)) { + if ($constraints === []) { if ($this->db->DBDebug) { throw new DatabaseException('No constraint found for upsert.'); } @@ -611,14 +655,20 @@ static function ($key, $value) use ($table, $alias, $that): RawSql|string { if (isset($this->QBOptions['setQueryAsData'])) { $data = $this->QBOptions['setQueryAsData']; } else { + $constraints = $this->QBOptions['constraints'] ?? []; + $data = implode( " UNION ALL\n", array_map( - static fn ($value): string => 'SELECT ' . implode(', ', array_map( - static fn ($key, $index): string => $index . ' ' . $key, + fn (array $value): string => sprintf('SELECT %s', implode(', ', array_map( + fn (string $key, float|int|string $index): string => sprintf( + '%s %s', + $this->castValue((string) $index, $this->getSourceType($table, $key, $constraints)), + $key, + ), $keys, $value, - )), + ))), $values, ), ) . "\n"; diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 4c3358a4b470..900f8f716c38 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -47,13 +47,28 @@ class Connection extends BaseConnection /** * Identifier escape character * - * @var string + * @var list|string */ public $escapeChar = '"'; + /** + * @var string|null + */ protected $connect_timeout; + + /** + * @var string|null + */ protected $options; + + /** + * @var string|null + */ protected $sslmode; + + /** + * @var string|null + */ protected $service; /** @@ -63,7 +78,7 @@ class Connection extends BaseConnection */ public function connect(bool $persistent = false) { - if (empty($this->DSN)) { + if ($this->DSN === null || $this->DSN === '') { $this->buildDSN(); } @@ -88,7 +103,7 @@ public function connect(bool $persistent = false) throw new DatabaseException($error); } - if (! empty($this->schema)) { + if ($this->schema !== '') { $this->simpleQuery("SET search_path TO {$this->schema},public"); } @@ -142,11 +157,6 @@ private function convertDSN() $this->DSN = $output; } - /** - * Close the database connection. - * - * @return void - */ protected function _close() { pg_close($this->connID); @@ -197,7 +207,13 @@ public function getVersion(): string protected function execute(string $sql) { try { - return pg_query($this->connID, $sql); + $result = pg_query($this->connID, $sql); + + if ($result === false && $this->DBDebug) { + throw new DatabaseException(pg_last_error($this->connID)); + } + + return $result; } catch (ErrorException $e) { $trace = array_slice($e->getTrace(), 2); // remove the call to error handler @@ -236,15 +252,6 @@ public function affectedRows(): int return pg_affected_rows($this->resultID); } - /** - * "Smart" Escape String - * - * Escapes data based on type - * - * @param mixed $str - * - * @return ($str is array ? array : float|int|string) - */ public function escape($str) { if (! $this->connID) { @@ -282,11 +289,6 @@ protected function _escapeString(string $str): string return pg_escape_string($this->connID, $str); } - /** - * Generates the SQL for listing tables in a platform-dependent manner. - * - * @param string|null $tableName If $tableName is provided will return only this table if exists. - */ protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string { $sql = 'SELECT "table_name" FROM "information_schema"."tables" WHERE "table_schema" = \'' . $this->schema . "'"; @@ -304,11 +306,6 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n return $sql; } - /** - * Generates a platform-specific query string so that the column names can be fetched. - * - * @param string|TableName $table - */ protected function _listColumns($table = ''): string { if ($table instanceof TableName) { @@ -324,19 +321,24 @@ protected function _listColumns($table = ''): string } /** - * Returns an array of objects with field data - * - * @return list - * * @throws DatabaseException */ protected function _fieldData(string $table): array { + $parts = explode('.', $table); + $table = array_pop($parts); + $schema = array_pop($parts); + $sql = 'SELECT "column_name", "data_type", "character_maximum_length", "numeric_precision", "column_default", "is_nullable" FROM "information_schema"."columns" WHERE LOWER("table_name") = ' - . $this->escape(strtolower($table)) - . ' ORDER BY "ordinal_position"'; + . $this->escape(strtolower($table)); + + if ($schema !== null) { + $sql .= ' AND LOWER("table_schema") = ' . $this->escape(strtolower($schema)); + } + + $sql .= ' ORDER BY "ordinal_position"'; if (($query = $this->query($sql)) === false) { throw new DatabaseException(lang('Database.failGetFieldData')); @@ -359,10 +361,6 @@ protected function _fieldData(string $table): array } /** - * Returns an array of objects with index data - * - * @return array - * * @throws DatabaseException */ protected function _indexData(string $table): array @@ -398,10 +396,6 @@ protected function _indexData(string $table): array } /** - * Returns an array of objects with Foreign key data - * - * @return array - * * @throws DatabaseException */ protected function _foreignKeyData(string $table): array @@ -578,25 +572,16 @@ protected function setClientEncoding(string $charset): bool return pg_set_client_encoding($this->connID, $charset) === 0; } - /** - * Begin Transaction - */ protected function _transBegin(): bool { return (bool) pg_query($this->connID, 'BEGIN'); } - /** - * Commit Transaction - */ protected function _transCommit(): bool { return (bool) pg_query($this->connID, 'COMMIT'); } - /** - * Rollback Transaction - */ protected function _transRollback(): bool { return (bool) pg_query($this->connID, 'ROLLBACK'); diff --git a/system/Database/Postgre/Forge.php b/system/Database/Postgre/Forge.php index d8e7d5886604..3f2c2321d352 100644 --- a/system/Database/Postgre/Forge.php +++ b/system/Database/Postgre/Forge.php @@ -17,6 +17,8 @@ /** * Forge for Postgre + * + * @extends BaseForge */ class Forge extends BaseForge { @@ -29,8 +31,6 @@ class Forge extends BaseForge /** * DROP CONSTRAINT statement - * - * @var string */ protected $dropConstraintStr = 'ALTER TABLE %s DROP CONSTRAINT %s'; @@ -44,7 +44,7 @@ class Forge extends BaseForge /** * UNSIGNED support * - * @var array + * @var array */ protected $_unsigned = [ 'INT2' => 'INTEGER', @@ -67,27 +67,16 @@ class Forge extends BaseForge */ protected $null = 'NULL'; - /** - * @var Connection - */ - protected $db; - /** * CREATE TABLE attributes * - * @param array $attributes Associative array of table attributes + * @param array $attributes Associative array of table attributes */ protected function _createTableAttributes(array $attributes): string { return ''; } - /** - * @param array|string $processedFields Processed column definitions - * or column names to DROP - * - * @return ($alterType is 'DROP' ? string : false|list) - */ protected function _alterTable(string $alterType, string $table, $processedFields) { if (in_array($alterType, ['DROP', 'ADD'], true)) { @@ -107,7 +96,7 @@ protected function _alterTable(string $alterType, string $table, $processedField . " TYPE {$field['type']}{$field['length']}"; } - if (! empty($field['default'])) { + if (($field['default'] ?? '') !== '') { $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) . " SET {$field['default']}"; } @@ -119,12 +108,12 @@ protected function _alterTable(string $alterType, string $table, $processedField $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) . ($nullable ? ' DROP' : ' SET') . ' NOT NULL'; - if (! empty($field['new_name'])) { + if (($field['new_name'] ?? '') !== '') { $sqls[] = $sql . ' RENAME COLUMN ' . $this->db->escapeIdentifiers($field['name']) . ' TO ' . $this->db->escapeIdentifiers($field['new_name']); } - if (! empty($field['comment'])) { + if (($field['comment'] ?? '') !== '') { $sqls[] = 'COMMENT ON COLUMN' . $this->db->escapeIdentifiers($table) . '.' . $this->db->escapeIdentifiers($field['name']) . " IS {$field['comment']}"; @@ -186,7 +175,7 @@ protected function _attributeType(array &$attributes) */ protected function _attributeAutoIncrement(array &$attributes, array &$field) { - if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true) { + if (($attributes['AUTO_INCREMENT'] ?? false) === true) { $field['type'] = $field['type'] === 'NUMERIC' || $field['type'] === 'BIGINT' ? 'BIGSERIAL' : 'SERIAL'; } } diff --git a/system/Database/Postgre/PreparedQuery.php b/system/Database/Postgre/PreparedQuery.php index 08900a6ddcfd..418259db7013 100644 --- a/system/Database/Postgre/PreparedQuery.php +++ b/system/Database/Postgre/PreparedQuery.php @@ -23,7 +23,7 @@ /** * Prepared query for Postgre * - * @extends BasePreparedQuery + * @extends BasePreparedQuery */ class PreparedQuery extends BasePreparedQuery { @@ -44,15 +44,6 @@ class PreparedQuery extends BasePreparedQuery protected $result; /** - * Prepares the query against the database, and saves the connection - * info necessary to execute the query later. - * - * NOTE: This version is based on SQL code. Child classes should - * override this method. - * - * @param array $options Passed to the connection's prepare statement. - * Unused in the MySQLi driver. - * * @throws Exception */ public function _prepare(string $sql, array $options = []): PreparedQuery @@ -77,10 +68,6 @@ public function _prepare(string $sql, array $options = []): PreparedQuery return $this; } - /** - * Takes a new set of data and runs it against the currently - * prepared query. Upon success, will return a Results object. - */ public function _execute(array $data): bool { if (! isset($this->statement)) { @@ -99,8 +86,6 @@ public function _execute(array $data): bool } /** - * Returns the result object for the prepared query or false on failure. - * * @return PgSqlResult|null */ public function _getResult() @@ -108,9 +93,6 @@ public function _getResult() return $this->result; } - /** - * Deallocate prepared statements. - */ protected function _close(): bool { return pg_query($this->db->connID, 'DEALLOCATE "' . $this->db->escapeIdentifiers($this->name) . '"') !== false; diff --git a/system/Database/Postgre/Result.php b/system/Database/Postgre/Result.php index e5d78c519c17..d071ff27f6f5 100644 --- a/system/Database/Postgre/Result.php +++ b/system/Database/Postgre/Result.php @@ -26,17 +26,11 @@ */ class Result extends BaseResult { - /** - * Gets the number of fields in the result set. - */ public function getFieldCount(): int { return pg_num_fields($this->resultID); } - /** - * Generates an array of column names in the result set. - */ public function getFieldNames(): array { $fieldNames = []; @@ -48,9 +42,6 @@ public function getFieldNames(): array return $fieldNames; } - /** - * Generates an array of objects representing field meta-data. - */ public function getFieldData(): array { $retVal = []; @@ -69,11 +60,6 @@ public function getFieldData(): array return $retVal; } - /** - * Frees the current result. - * - * @return void - */ public function freeResult() { if ($this->resultID !== false) { @@ -82,49 +68,30 @@ public function freeResult() } } - /** - * Moves the internal pointer to the desired offset. This is called - * internally before fetching results to make sure the result set - * starts at zero. - * - * @return bool - */ public function dataSeek(int $n = 0) { return pg_result_seek($this->resultID, $n); } /** - * Returns the result set as an array. - * - * Overridden by driver classes. - * - * @return array|false + * @return array|false */ protected function fetchAssoc() { return pg_fetch_assoc($this->resultID); } - /** - * Returns the result set as an object. - * - * Overridden by child classes. - * - * @return Entity|false|object|stdClass - */ - protected function fetchObject(string $className = 'stdClass') + protected function fetchObject(string $className = stdClass::class) { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + $data = $this->fetchAssoc(); + + return $data === false || $data === [] ? false : (new $className())->injectRawData($data); } return pg_fetch_object($this->resultID, null, $className); } - /** - * Returns the number of rows in the resultID (i.e., PostgreSQL query result resource) - */ public function getNumRows(): int { if (! is_int($this->numRows)) { diff --git a/system/Database/Postgre/Utils.php b/system/Database/Postgre/Utils.php index c3b7b82d1681..ab2e3bd6d056 100644 --- a/system/Database/Postgre/Utils.php +++ b/system/Database/Postgre/Utils.php @@ -18,26 +18,22 @@ /** * Utils for Postgre + * + * @extends BaseUtils */ class Utils extends BaseUtils { /** - * List databases statement - * - * @var string + * @var bool|string */ protected $listDatabases = 'SELECT datname FROM pg_database'; /** - * OPTIMIZE TABLE statement - * - * @var string + * @var bool|string */ protected $optimizeTable = 'REINDEX TABLE %s'; /** - * Platform dependent version of the backup function. - * * @return never */ public function _backup(?array $prefs = null) diff --git a/system/Database/PreparedQueryInterface.php b/system/Database/PreparedQueryInterface.php index c49c2e7bb551..fa2c2a9aac28 100644 --- a/system/Database/PreparedQueryInterface.php +++ b/system/Database/PreparedQueryInterface.php @@ -26,6 +26,8 @@ interface PreparedQueryInterface * Takes a new set of data and runs it against the currently * prepared query. Upon success, will return a Results object. * + * @param mixed ...$data + * * @return bool|ResultInterface */ public function execute(...$data); @@ -34,12 +36,14 @@ public function execute(...$data); * Prepares the query against the database, and saves the connection * info necessary to execute the query later. * + * @param array $options + * * @return $this */ public function prepare(string $sql, array $options = []); /** - * Explicity closes the statement. + * Explicitly closes the statement. * * @throws BadMethodCallException */ diff --git a/system/Database/Query.php b/system/Database/Query.php index 8040f54b666c..41d21c93330d 100644 --- a/system/Database/Query.php +++ b/system/Database/Query.php @@ -44,7 +44,7 @@ class Query implements QueryInterface, Stringable /** * The binds and their values used for binding. * - * @var array + * @var array */ protected $binds = []; @@ -85,7 +85,7 @@ class Query implements QueryInterface, Stringable * * @var string */ - protected $errorString; + protected $errorString = ''; /** * Pointer to database connection. @@ -129,6 +129,8 @@ public function setQuery(string $sql, mixed $binds = null, bool $setEscape = tru /** * Will store the variables to bind into the query later. * + * @param array $binds + * * @return $this */ public function setBinds(array $binds, bool $setEscape = true) @@ -148,7 +150,7 @@ public function setBinds(array $binds, bool $setEscape = true) public function getQuery(): string { - if (empty($this->finalQueryString)) { + if (! isset($this->finalQueryString) || $this->finalQueryString === '') { $this->compileBinds(); } @@ -159,9 +161,7 @@ public function setDuration(float $start, ?float $end = null): self { $this->startTime = $start; - if ($end === null) { - $end = microtime(true); - } + $end ??= microtime(true); $this->endTime = $end; @@ -197,7 +197,7 @@ public function setError(int $code, string $error): self public function hasError(): bool { - return ! empty($this->errorString); + return $this->errorString !== ''; } public function getErrorCode(): int @@ -246,7 +246,7 @@ protected function compileBinds() $sql = $this->swappedQueryString ?? $this->originalQueryString; $binds = $this->binds; - if (empty($binds)) { + if ($binds === []) { $this->finalQueryString = $sql; return; @@ -266,6 +266,9 @@ protected function compileBinds() } } + /** + * @param array $binds + */ protected function matchNamedBinds(string $sql, array $binds): string { $replacers = []; @@ -287,6 +290,9 @@ protected function matchNamedBinds(string $sql, array $binds): string return strtr($sql, $replacers); } + /** + * @param array $binds + */ protected function matchSimpleBinds(string $sql, array $binds, int $bindCount, int $ml): string { if ($c = preg_match_all("/'[^']*'/", $sql, $matches) >= 1) { diff --git a/system/Database/ResultInterface.php b/system/Database/ResultInterface.php index 89febfef835c..afe6ede83390 100644 --- a/system/Database/ResultInterface.php +++ b/system/Database/ResultInterface.php @@ -26,16 +26,22 @@ interface ResultInterface * individual data rows, which can be either an 'array', an * 'object', or a custom class name. * - * @param string $type The row type. Either 'array', 'object', or a class name to use + * @template T of object + * + * @param 'array'|'object'|class-string $type The row type. Either 'array', 'object', or a class name to use + * + * @return ($type is 'array' ? list> : ($type is 'object' ? list : list)) */ public function getResult(string $type = 'object'): array; /** * Returns the results as an array of custom objects. * - * @param string $className The name of the class to use. + * @template T of object * - * @return array + * @param class-string $className The name of the class to use. + * + * @return list */ public function getCustomResultObject(string $className); @@ -43,6 +49,8 @@ public function getCustomResultObject(string $className); * Returns the results as an array of arrays. * * If no results, an empty array is returned. + * + * @return list> */ public function getResultArray(): array; @@ -50,6 +58,8 @@ public function getResultArray(): array; * Returns the results as an array of objects. * * If no results, an empty array is returned. + * + * @return list */ public function getResultObject(): array; @@ -64,7 +74,7 @@ public function getResultObject(): array; * @param int|string $n The index of the results to return, or column name. * @param 'array'|'object'|class-string $type The type of result object. 'array', 'object' or class name. * - * @return ($n is string ? float|int|string|null : ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : T|null))) + * @return ($n is string ? float|int|string|null : ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : T|null))) */ public function getRow($n = 0, string $type = 'object'); @@ -87,7 +97,7 @@ public function getCustomRowObject(int $n, string $className); * * If row doesn't exist, returns null. * - * @return array|null + * @return array|null */ public function getRowArray(int $n = 0); @@ -96,15 +106,15 @@ public function getRowArray(int $n = 0); * * If row doesn't exist, returns null. * - * @return object|stdClass|null + * @return stdClass|null */ public function getRowObject(int $n = 0); /** * Assigns an item into a particular column slot. * - * @param array|string $key - * @param array|object|stdClass|null $value + * @param array|string $key + * @param array|object|null $value * * @return void */ @@ -113,28 +123,44 @@ public function setRow($key, $value = null); /** * Returns the "first" row of the current results. * - * @return array|object|null + * @template T of object + * + * @param 'array'|'object'|class-string $type The type of result object. 'array', 'object' or class name. + * + * @return ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : T|null)) */ public function getFirstRow(string $type = 'object'); /** * Returns the "last" row of the current results. * - * @return array|object|null + * @template T of object + * + * @param 'array'|'object'|class-string $type The type of result object. 'array', 'object' or class name. + * + * @return ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : T|null)) */ public function getLastRow(string $type = 'object'); /** * Returns the "next" row of the current results. * - * @return array|object|null + * @template T of object + * + * @param 'array'|'object'|class-string $type The type of result object. 'array', 'object' or class name. + * + * @return ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : T|null)) */ public function getNextRow(string $type = 'object'); /** * Returns the "previous" row of the current results. * - * @return array|object|null + * @template T of object + * + * @param 'array'|'object'|class-string $type The type of result object. 'array', 'object' or class name. + * + * @return ($type is 'object' ? stdClass|null : ($type is 'array' ? array|null : T|null)) */ public function getPreviousRow(string $type = 'object'); @@ -146,7 +172,7 @@ public function getNumRows(): int; /** * Returns an unbuffered row and move the pointer to the next row. * - * @return array|object|null + * @return array|object|null */ public function getUnbufferedRow(string $type = 'object'); @@ -157,11 +183,15 @@ public function getFieldCount(): int; /** * Generates an array of column names in the result set. + * + * @return list */ public function getFieldNames(): array; /** * Generates an array of objects representing field meta-data. + * + * @return list */ public function getFieldData(): array; diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index cd7e7968fe72..d98cbe0d9d0b 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -28,13 +28,15 @@ * @todo auto check for TextCastToInt * @todo auto check for InsertIndexValue * @todo replace: delete index entries before insert + * + * @extends BaseBuilder */ class Builder extends BaseBuilder { /** * ORDER BY random keyword * - * @var array + * @var list */ protected $randomKeyword = [ 'NEWID()', @@ -224,7 +226,7 @@ protected function _update(string $table, array $values): string $fullTableName = $this->getFullName($table); - $statement = sprintf('UPDATE %s%s SET ', empty($this->QBLimit) ? '' : 'TOP(' . $this->QBLimit . ') ', $fullTableName); + $statement = sprintf('UPDATE %s%s SET ', $this->QBLimit === false || $this->QBLimit === 0 ? '' : 'TOP(' . $this->QBLimit . ') ', $fullTableName); $statement .= implode(', ', $valstr) . $this->compileWhereHaving('QBWhere') @@ -344,7 +346,7 @@ protected function _limit(string $sql, bool $offsetIgnore = false): string return "SELECT * \nFROM " . $this->_fromTables() . ' WHERE 1=0 '; } - if (empty($this->QBOrderBy)) { + if (! is_array($this->QBOrderBy) || $this->QBOrderBy === []) { $sql .= ' ORDER BY (SELECT NULL) '; } @@ -514,7 +516,7 @@ public function countAll(bool $reset = true) } $query = $this->db->query($sql, null, false); - if (empty($query->getResult())) { + if ($query->getResult() === []) { return 0; } @@ -532,13 +534,13 @@ public function countAll(bool $reset = true) */ protected function _delete(string $table): string { - return 'DELETE' . (empty($this->QBLimit) ? '' : ' TOP (' . $this->QBLimit . ') ') . ' FROM ' . $this->getFullName($table) . $this->compileWhereHaving('QBWhere'); + return 'DELETE' . ($this->QBLimit === false || $this->QBLimit === 0 ? '' : ' TOP (' . $this->QBLimit . ') ') . ' FROM ' . $this->getFullName($table) . $this->compileWhereHaving('QBWhere'); } /** * Compiles a delete string and runs the query * - * @param array|RawSql|string $where + * @param array|RawSql|string $where * * @return bool|string * @@ -589,13 +591,13 @@ protected function compileSelect($selectOverride = false): string $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT '; // SQL Server can't work with select * if group by is specified - if (empty($this->QBSelect) && $this->QBGroupBy !== [] && is_array($this->QBGroupBy)) { + if ($this->QBSelect === [] && $this->QBGroupBy !== [] && is_array($this->QBGroupBy)) { foreach ($this->QBGroupBy as $field) { $this->QBSelect[] = is_array($field) ? $field['field'] : $field; } } - if (empty($this->QBSelect)) { + if ($this->QBSelect === []) { $sql .= '*'; } else { // Cycle through the "select" portion of the query and prep each column name. @@ -616,7 +618,7 @@ protected function compileSelect($selectOverride = false): string } // Write the "JOIN" portion of the query - if (! empty($this->QBJoin)) { + if ($this->QBJoin !== []) { $sql .= "\n" . implode("\n", $this->QBJoin); } @@ -642,7 +644,7 @@ protected function compileSelect($selectOverride = false): string * Compiles the select statement based on the other functions called * and runs the query * - * @return ResultInterface + * @return false|ResultInterface|string */ public function get(?int $limit = null, int $offset = 0, bool $reset = true) { @@ -698,7 +700,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $fieldNames = array_map(static fn ($columnName): string => trim($columnName, '"'), $keys); - if (empty($constraints)) { + if ($constraints === []) { $tableIndexes = $this->db->getIndexData($table); $uniqueIndexes = array_filter($tableIndexes, static function ($index) use ($fieldNames): bool { @@ -725,7 +727,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; } - if (empty($constraints)) { + if ($constraints === []) { if ($this->db->DBDebug) { throw new DatabaseException('No constraint found for upsert.'); } diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 970c582f60ed..2bfbdabc57e3 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -47,14 +47,14 @@ class Connection extends BaseConnection * FALSE or SQLSRV_CURSOR_FORWARD would increase performance, * but would disable num_rows() (and possibly insert_id()) * - * @var false|string + * @var false|string|null */ public $scrollable; /** * Identifier escape character * - * @var string + * @var list|string */ public $escapeChar = '"'; @@ -84,17 +84,11 @@ class Connection extends BaseConnection */ protected $_reserved_identifiers = ['*']; - /** - * Class constructor - */ public function __construct(array $params) { parent::__construct($params); - // This is only supported as of SQLSRV 3.0 - if ($this->scrollable === null) { - $this->scrollable = defined('SQLSRV_CURSOR_CLIENT_BUFFERED') ? SQLSRV_CURSOR_CLIENT_BUFFERED : false; - } + $this->scrollable ??= defined('SQLSRV_CURSOR_CLIENT_BUFFERED') ? SQLSRV_CURSOR_CLIENT_BUFFERED : false; } /** @@ -109,8 +103,8 @@ 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, + 'UID' => is_string($this->username) ? $this->username : '', + 'PWD' => is_string($this->password) ? $this->password : '', 'Database' => $this->database, 'ConnectionPooling' => $persistent ? 1 : 0, 'CharacterSet' => $charset, @@ -120,7 +114,7 @@ public function connect(bool $persistent = false) // If the username and password are both empty, assume this is a // 'Windows Authentication Mode' connection. - if (empty($connection['UID']) && empty($connection['PWD'])) { + if ($connection['UID'] === '' && $connection['PWD'] === '') { unset($connection['UID'], $connection['PWD']); } @@ -136,7 +130,7 @@ public function connect(bool $persistent = false) $query = $this->query('SELECT CASE WHEN (@@OPTIONS | 256) = @@OPTIONS THEN 1 ELSE 0 END AS qi'); $query = $query->getResultObject(); - $this->_quoted_identifier = empty($query) ? false : (bool) $query[0]->qi; + $this->_quoted_identifier = $query === [] ? false : (bool) $query[0]->qi; $this->escapeChar = ($this->_quoted_identifier) ? '"' : ['[', ']']; return $this->connID; @@ -166,11 +160,6 @@ public function getAllErrorMessages(): string return implode("\n", $errors); } - /** - * Close the database connection. - * - * @return void - */ protected function _close() { sqlsrv_close($this->connID); @@ -192,11 +181,6 @@ public function insertID(): int return (int) ($this->query('SELECT SCOPE_IDENTITY() AS insert_id')->getRow()->insert_id ?? 0); } - /** - * Generates the SQL for listing tables in a platform-dependent manner. - * - * @param string|null $tableName If $tableName is provided will return only this table if exists. - */ protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string { $sql = 'SELECT [TABLE_NAME] AS "name"' @@ -216,11 +200,6 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n return $sql; } - /** - * Generates a platform-specific query string so that the column names can be fetched. - * - * @param string|TableName $table - */ protected function _listColumns($table = ''): string { if ($table instanceof TableName) { @@ -236,10 +215,6 @@ protected function _listColumns($table = ''): string } /** - * Returns an array of objects with index data - * - * @return array - * * @throws DatabaseException */ protected function _indexData(string $table): array @@ -273,11 +248,6 @@ protected function _indexData(string $table): array } /** - * Returns an array of objects with Foreign key data - * referenced_object_id parent_object_id - * - * @return array - * * @throws DatabaseException */ protected function _foreignKeyData(string $table): array @@ -340,20 +310,24 @@ protected function _enableForeignKeyChecks() } /** - * Returns an array of objects with field data - * - * @return list - * * @throws DatabaseException */ protected function _fieldData(string $table): array { + $parts = explode('.', $table); + $table = array_pop($parts); + $schema = array_pop($parts); + $sql = 'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, COLUMN_DEFAULT, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME= ' . $this->escape(($table)); + if ($schema !== null) { + $sql .= ' AND TABLE_SCHEMA = ' . $this->escape($schema); + } + if (($query = $this->query($sql)) === false) { throw new DatabaseException(lang('Database.failGetFieldData')); } @@ -412,25 +386,16 @@ private function normalizeDefault(?string $default): ?string return $default; } - /** - * Begin Transaction - */ protected function _transBegin(): bool { return sqlsrv_begin_transaction($this->connID); } - /** - * Commit Transaction - */ protected function _transCommit(): bool { return sqlsrv_commit($this->connID); } - /** - * Rollback Transaction - */ protected function _transRollback(): bool { return sqlsrv_rollback($this->connID); @@ -493,7 +458,7 @@ public function setDatabase(?string $databaseName = null) $databaseName = $this->database; } - if (empty($this->connID)) { + if ($this->connID === false) { $this->initialize(); } diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php index 64a4ffaf6d35..fa29fe5dbd75 100644 --- a/system/Database/SQLSRV/Forge.php +++ b/system/Database/SQLSRV/Forge.php @@ -20,13 +20,13 @@ /** * Forge for SQLSRV + * + * @extends BaseForge */ class Forge extends BaseForge { /** * DROP CONSTRAINT statement - * - * @var string */ protected $dropConstraintStr; @@ -50,8 +50,6 @@ class Forge extends BaseForge * CREATE DATABASE IF statement * * @todo missing charset & collat - * - * @var string */ protected $createDatabaseStr = 'CREATE DATABASE %s '; @@ -70,15 +68,11 @@ class Forge extends BaseForge * * @see https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-rename-transact-sql?view=sql-server-2017 * 'EXEC sp_rename %s , %s ;' - * - * @var string */ protected $renameTableStr; /** * UNSIGNED support - * - * @var array */ protected $unsigned = [ 'TINYINT' => 'SMALLINT', @@ -89,24 +83,18 @@ class Forge extends BaseForge /** * Foreign Key Allowed Actions - * - * @var array */ 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 - * - * @var string */ protected $createTableStr; @@ -121,13 +109,6 @@ public function __construct(BaseConnection $db) $this->dropIndexStr = 'DROP INDEX %s ON ' . $this->db->escapeIdentifiers($this->db->schema) . '.%s'; } - /** - * Create database - * - * @param bool $ifNotExists Whether to add IF NOT EXISTS condition - * - * @throws DatabaseException - */ public function createDatabase(string $dbName, bool $ifNotExists = false): bool { if ($ifNotExists) { @@ -187,20 +168,11 @@ public function dropDatabase(string $dbName): bool return parent::dropDatabase($dbName); } - /** - * CREATE TABLE attributes - */ protected function _createTableAttributes(array $attributes): string { return ''; } - /** - * @param array|string $processedFields Processed column definitions - * or column names to DROP - * - * @return ($alterType is 'DROP' ? string : false|list) - */ protected function _alterTable(string $alterType, string $table, $processedFields) { // Handle DROP here @@ -271,7 +243,7 @@ protected function _alterTable(string $alterType, string $table, $processedField . " {$field['type']}{$field['length']}"; } - if (! empty($field['default'])) { + if (($field['default'] ?? '') !== '') { $fullTable = $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->escapeIdentifiers($table); $colName = $field['name']; // bare, for sys.columns lookup @@ -302,7 +274,7 @@ protected function _alterTable(string $alterType, string $table, $processedField $sqls[] = $sql . ' ALTER COLUMN ' . $this->db->escapeIdentifiers($field['name']) . " {$field['type']}{$field['length']} " . ($nullable ? '' : 'NOT') . ' NULL'; - if (! empty($field['comment'])) { + if (($field['comment'] ?? '') !== '') { $sqls[] = 'EXEC sys.sp_addextendedproperty ' . "@name=N'Caption', @value=N'" . $field['comment'] . "' , " . "@level0type=N'SCHEMA',@level0name=N'" . $this->db->schema . "', " @@ -310,7 +282,7 @@ protected function _alterTable(string $alterType, string $table, $processedField . "@level2type=N'COLUMN',@level2name=N'" . $this->db->escapeIdentifiers($field['name']) . "'"; } - if (! empty($field['new_name'])) { + if (($field['new_name'] ?? '') !== '') { $sqls[] = "EXEC sp_rename '[" . $this->db->schema . '].[' . $table . '].[' . $field['name'] . "]' , '" . $field['new_name'] . "', 'COLUMN';"; } } @@ -334,11 +306,6 @@ protected function _dropIndex(string $table, object $indexData) return $this->db->simpleQuery($sql); } - /** - * Generates SQL to add indexes - * - * @param bool $asQuery When true returns stand alone SQL, else partial SQL used with CREATE TABLE - */ protected function _processIndexes(string $table, bool $asQuery = false): array { $sqls = []; @@ -382,7 +349,7 @@ protected function _processIndexes(string $table, bool $asQuery = false): array protected function _processColumn(array $processedField): string { return $this->db->escapeIdentifiers($processedField['name']) - . (empty($processedField['new_name']) ? '' : ' ' . $this->db->escapeIdentifiers($processedField['new_name'])) + . (($processedField['new_name'] ?? '') === '' ? '' : ' ' . $this->db->escapeIdentifiers($processedField['new_name'])) . ' ' . $processedField['type'] . ($processedField['type'] === 'text' ? '' : $processedField['length']) . $processedField['default'] . $processedField['null'] @@ -449,7 +416,7 @@ protected function _attributeType(array &$attributes) */ protected function _attributeAutoIncrement(array &$attributes, array &$field) { - if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true && str_contains(strtolower($field['type']), strtolower('INT'))) { + if (($attributes['AUTO_INCREMENT'] ?? false) === true && str_contains(strtolower($field['type']), strtolower('INT'))) { $field['auto_increment'] = ' IDENTITY(1,1)'; } } diff --git a/system/Database/SQLSRV/PreparedQuery.php b/system/Database/SQLSRV/PreparedQuery.php index 19d2d5adfe6b..9e21eed19000 100644 --- a/system/Database/SQLSRV/PreparedQuery.php +++ b/system/Database/SQLSRV/PreparedQuery.php @@ -18,39 +18,26 @@ use CodeIgniter\Exceptions\BadMethodCallException; /** - * Prepared query for Postgre + * Prepared query for SQLSRV * - * @extends BasePreparedQuery + * @extends BasePreparedQuery */ class PreparedQuery extends BasePreparedQuery { /** * Parameters array used to store the dynamic variables. * - * @var array + * @var array */ protected $parameters = []; - /** - * A reference to the db connection to use. - * - * @var Connection - */ - protected $db; - public function __construct(Connection $db) { parent::__construct($db); } /** - * Prepares the query against the database, and saves the connection - * info necessary to execute the query later. - * - * NOTE: This version is based on SQL code. Child classes should - * override this method. - * - * @param array $options Options takes an associative array; + * @param array $options Positional array of bind values, keyed by placeholder index. * * @throws DatabaseException */ @@ -77,10 +64,6 @@ public function _prepare(string $sql, array $options = []): PreparedQuery return $this; } - /** - * Takes a new set of data and runs it against the currently - * prepared query. - */ public function _execute(array $data): bool { if (! isset($this->statement)) { @@ -101,8 +84,6 @@ public function _execute(array $data): bool } /** - * Returns the statement resource for the prepared query or false when preparing failed. - * * @return resource|null */ public function _getResult() @@ -110,9 +91,6 @@ public function _getResult() return $this->statement; } - /** - * Deallocate prepared statements. - */ protected function _close(): bool { return sqlsrv_free_stmt($this->statement); @@ -121,7 +99,9 @@ protected function _close(): bool /** * Handle parameters. * - * @param array $options + * @param array $options + * + * @return list */ protected function parameterize(string $queryString, array $options): array { diff --git a/system/Database/SQLSRV/Result.php b/system/Database/SQLSRV/Result.php index 4d9b836fdda0..5a5f3beed997 100644 --- a/system/Database/SQLSRV/Result.php +++ b/system/Database/SQLSRV/Result.php @@ -24,17 +24,11 @@ */ class Result extends BaseResult { - /** - * Gets the number of fields in the result set. - */ public function getFieldCount(): int { return @sqlsrv_num_fields($this->resultID); } - /** - * Generates an array of column names in the result set. - */ public function getFieldNames(): array { $fieldNames = []; @@ -46,9 +40,6 @@ public function getFieldNames(): array return $fieldNames; } - /** - * Generates an array of objects representing field meta-data. - */ public function getFieldData(): array { static $dataTypes = [ @@ -103,11 +94,6 @@ public function getFieldData(): array return $retVal; } - /** - * Frees the current result. - * - * @return void - */ public function freeResult() { if (is_resource($this->resultID)) { @@ -116,13 +102,6 @@ public function freeResult() } } - /** - * Moves the internal pointer to the desired offset. This is called - * internally before fetching results to make sure the result set - * starts at zero. - * - * @return bool - */ public function dataSeek(int $n = 0) { if ($n > 0) { @@ -136,35 +115,22 @@ public function dataSeek(int $n = 0) return true; } - /** - * Returns the result set as an array. - * - * Overridden by driver classes. - * - * @return array|false|null - */ protected function fetchAssoc() { return sqlsrv_fetch_array($this->resultID, SQLSRV_FETCH_ASSOC); } - /** - * Returns the result set as an object. - * - * @return Entity|false|object|stdClass - */ - protected function fetchObject(string $className = 'stdClass') + protected function fetchObject(string $className = stdClass::class) { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); + $data = $this->fetchAssoc(); + + return in_array($data, [null, false, []], true) ? false : (new $className())->injectRawData($data); } return sqlsrv_fetch_object($this->resultID, $className); } - /** - * Returns the number of rows in the resultID (i.e., SQLSRV query result resource) - */ public function getNumRows(): int { if (! is_int($this->numRows)) { diff --git a/system/Database/SQLSRV/Utils.php b/system/Database/SQLSRV/Utils.php index f74ac39f7a88..1e7fe45730a2 100644 --- a/system/Database/SQLSRV/Utils.php +++ b/system/Database/SQLSRV/Utils.php @@ -19,20 +19,18 @@ /** * Utils for SQLSRV + * + * @extends BaseUtils */ class Utils extends BaseUtils { /** - * List databases statement - * - * @var string + * @var bool|string */ protected $listDatabases = 'EXEC sp_helpdb'; // Can also be: EXEC sp_databases /** - * OPTIMIZE TABLE statement - * - * @var string + * @var bool|string */ protected $optimizeTable = 'ALTER INDEX all ON %s REORGANIZE'; @@ -44,8 +42,6 @@ public function __construct(ConnectionInterface $db) } /** - * Platform dependent version of the backup function. - * * @return never */ public function _backup(?array $prefs = null) diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index 4f8dff97a0ea..be6bcbe1a0c5 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -20,6 +20,8 @@ /** * Builder for SQLite3 + * + * @extends BaseBuilder */ class Builder extends BaseBuilder { @@ -42,7 +44,7 @@ class Builder extends BaseBuilder /** * ORDER BY random keyword * - * @var array + * @var list */ protected $randomKeyword = [ 'RANDOM()', @@ -142,7 +144,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri if ($sql === '') { $constraints = $this->QBOptions['constraints'] ?? []; - if (empty($constraints)) { + if ($constraints === []) { $fieldNames = array_map(static fn ($columnName): string => trim($columnName, '`'), $keys); $allIndexes = array_filter($this->db->getIndexData($table), static function ($index) use ($fieldNames): bool { @@ -159,7 +161,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? []; } - if (empty($constraints)) { + if ($constraints === []) { if ($this->db->DBDebug) { throw new DatabaseException('No constraint found for upsert.'); } @@ -240,7 +242,7 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri // @codeCoverageIgnore } - if (is_string(current(array_keys($constraints)))) { + if (is_string(array_key_first($constraints))) { $concat1 = implode(' || ', array_keys($constraints)); $concat2 = implode(' || ', array_values($constraints)); } else { diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 268ceaa7bea2..582ab2b4b2b1 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -39,7 +39,7 @@ class Connection extends BaseConnection /** * Identifier escape character * - * @var string + * @var list|string */ public $escapeChar = '`'; @@ -119,11 +119,6 @@ public function connect(bool $persistent = false) } } - /** - * Close the database connection. - * - * @return void - */ protected function _close() { $this->connID->close(); @@ -198,11 +193,6 @@ protected function _escapeString(string $str): string return $this->connID->escapeString($str); } - /** - * Generates the SQL for listing tables in a platform-dependent manner. - * - * @param string|null $tableName If $tableName is provided will return only this table if exists. - */ protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string { if ((string) $tableName !== '') { @@ -218,11 +208,6 @@ protected function _listTables(bool $prefixLimit = false, ?string $tableName = n : ''); } - /** - * Generates a platform-specific query string so that the column names can be fetched. - * - * @param string|TableName $table - */ protected function _listColumns($table = ''): string { if ($table instanceof TableName) { @@ -281,21 +266,22 @@ public function getFieldNames($tableName) } /** - * Returns an array of objects with field data - * - * @return list - * * @throws DatabaseException */ protected function _fieldData(string $table): array { - if (false === $query = $this->query('PRAGMA TABLE_INFO(' . $this->protectIdentifiers($table, true, null, false) . ')')) { + $parts = explode('.', $table); + $table = array_pop($parts); + $schema = array_pop($parts); + $pragma = ($schema === null ? '' : $this->escapeIdentifiers($schema) . '.') . 'TABLE_INFO(' . $this->protectIdentifiers($table, true, null, false) . ')'; + + if (false === $query = $this->query('PRAGMA ' . $pragma)) { throw new DatabaseException(lang('Database.failGetFieldData')); } $query = $query->getResultObject(); - if (empty($query)) { + if ($query === []) { return []; } @@ -319,10 +305,6 @@ protected function _fieldData(string $table): array } /** - * Returns an array of objects with index data - * - * @return array - * * @throws DatabaseException */ protected function _indexData(string $table): array @@ -375,11 +357,6 @@ protected function _indexData(string $table): array return $retVal; } - /** - * Returns an array of objects with Foreign key data - * - * @return array - */ protected function _foreignKeyData(string $table): array { if (! $this->supportsForeignKeys()) { @@ -446,25 +423,16 @@ public function insertID(): int return $this->connID->lastInsertRowID(); } - /** - * Begin Transaction - */ protected function _transBegin(): bool { return $this->connID->exec('BEGIN TRANSACTION'); } - /** - * Commit Transaction - */ protected function _transCommit(): bool { return $this->connID->exec('END TRANSACTION'); } - /** - * Rollback Transaction - */ protected function _transRollback(): bool { return $this->connID->exec('ROLLBACK'); diff --git a/system/Database/SQLite3/Forge.php b/system/Database/SQLite3/Forge.php index 2bb9386f6c9e..2ca26bba105e 100644 --- a/system/Database/SQLite3/Forge.php +++ b/system/Database/SQLite3/Forge.php @@ -19,6 +19,8 @@ /** * Forge for SQLite3 + * + * @extends BaseForge */ class Forge extends BaseForge { @@ -29,15 +31,10 @@ class Forge extends BaseForge */ protected $dropIndexStr = 'DROP INDEX %s'; - /** - * @var Connection - */ - protected $db; - /** * UNSIGNED support * - * @var array|bool + * @var array|bool */ protected $_unsigned = false; @@ -62,11 +59,6 @@ public function __construct(BaseConnection $db) } } - /** - * Create database - * - * @param bool $ifNotExists Whether to add IF NOT EXISTS condition - */ public function createDatabase(string $dbName, bool $ifNotExists = false): bool { // In SQLite, a database is created when you connect to the database. @@ -100,7 +92,7 @@ public function dropDatabase(string $dbName): bool return false; } - if (! empty($this->db->dataCache['db_names'])) { + if (($this->db->dataCache['db_names'] ?? []) !== []) { $key = array_search(strtolower($dbName), array_map(strtolower(...), $this->db->dataCache['db_names']), true); if ($key !== false) { unset($this->db->dataCache['db_names'][$key]); @@ -135,12 +127,6 @@ public function dropColumn(string $table, $columnNames): bool return $result; } - /** - * @param array|string $processedFields Processed column definitions - * or column names to DROP - * - * @return ($alterType is 'DROP' ? string : list|null) - */ protected function _alterTable(string $alterType, string $table, $processedFields) { switch ($alterType) { @@ -222,8 +208,7 @@ protected function _attributeType(array &$attributes) protected function _attributeAutoIncrement(array &$attributes, array &$field) { if ( - ! empty($attributes['AUTO_INCREMENT']) - && $attributes['AUTO_INCREMENT'] === true + ($attributes['AUTO_INCREMENT'] ?? false) === true && str_contains(strtolower($field['type']), 'int') ) { $field['type'] = 'INTEGER PRIMARY KEY'; diff --git a/system/Database/SQLite3/PreparedQuery.php b/system/Database/SQLite3/PreparedQuery.php index afd2669c6436..1986961f648a 100644 --- a/system/Database/SQLite3/PreparedQuery.php +++ b/system/Database/SQLite3/PreparedQuery.php @@ -24,7 +24,7 @@ /** * Prepared query for SQLite3 * - * @extends BasePreparedQuery + * @extends BasePreparedQuery */ class PreparedQuery extends BasePreparedQuery { @@ -35,16 +35,6 @@ class PreparedQuery extends BasePreparedQuery */ protected $result; - /** - * Prepares the query against the database, and saves the connection - * info necessary to execute the query later. - * - * NOTE: This version is based on SQL code. Child classes should - * override this method. - * - * @param array $options Passed to the connection's prepare statement. - * Unused in the MySQLi driver. - */ public function _prepare(string $sql, array $options = []): PreparedQuery { if (! ($this->statement = $this->db->connID->prepare($sql))) { @@ -59,10 +49,6 @@ public function _prepare(string $sql, array $options = []): PreparedQuery return $this; } - /** - * Takes a new set of data and runs it against the currently - * prepared query. Upon success, will return a Results object. - */ public function _execute(array $data): bool { if (! isset($this->statement)) { @@ -99,8 +85,6 @@ public function _execute(array $data): bool } /** - * Returns the result object for the prepared query or false on failure. - * * @return false|SQLite3Result */ public function _getResult() @@ -108,9 +92,6 @@ public function _getResult() return $this->result; } - /** - * Deallocate prepared statements. - */ protected function _close(): bool { return $this->statement->close(); diff --git a/system/Database/SQLite3/Result.php b/system/Database/SQLite3/Result.php index aa5abf28682a..a3c8556fccfe 100644 --- a/system/Database/SQLite3/Result.php +++ b/system/Database/SQLite3/Result.php @@ -28,17 +28,11 @@ */ class Result extends BaseResult { - /** - * Gets the number of fields in the result set. - */ public function getFieldCount(): int { return $this->resultID->numColumns(); } - /** - * Generates an array of column names in the result set. - */ public function getFieldNames(): array { $fieldNames = []; @@ -50,9 +44,6 @@ public function getFieldNames(): array return $fieldNames; } - /** - * Generates an array of objects representing field meta-data. - */ public function getFieldData(): array { static $dataTypes = [ @@ -80,11 +71,6 @@ public function getFieldData(): array return $retVal; } - /** - * Frees the current result. - * - * @return void - */ public function freeResult() { if (is_object($this->resultID)) { @@ -94,12 +80,6 @@ public function freeResult() } /** - * Moves the internal pointer to the desired offset. This is called - * internally before fetching results to make sure the result set - * starts at zero. - * - * @return bool - * * @throws DatabaseException */ public function dataSeek(int $n = 0) @@ -112,25 +92,14 @@ public function dataSeek(int $n = 0) } /** - * Returns the result set as an array. - * - * Overridden by driver classes. - * - * @return array|false + * @return array|false */ protected function fetchAssoc() { return $this->resultID->fetchArray(SQLITE3_ASSOC); } - /** - * Returns the result set as an object. - * - * Overridden by child classes. - * - * @return Entity|false|object|stdClass - */ - protected function fetchObject(string $className = 'stdClass') + protected function fetchObject(string $className = stdClass::class) { // No native support for fetching rows as objects if (($row = $this->fetchAssoc()) === false) { diff --git a/system/Database/SQLite3/Table.php b/system/Database/SQLite3/Table.php index ea84e0011e3f..3737822eaeeb 100644 --- a/system/Database/SQLite3/Table.php +++ b/system/Database/SQLite3/Table.php @@ -35,14 +35,14 @@ class Table /** * All of the unique/primary keys in the table. * - * @var array + * @var array, type: string}> */ protected $keys = []; /** * All of the foreign keys in the table. * - * @var array + * @var array */ protected $foreignKeys = []; @@ -96,7 +96,7 @@ public function fromTable(string $table) $prefix = $this->db->DBPrefix; - if (! empty($prefix) && str_starts_with($table, $prefix)) { + if ($prefix !== '' && str_starts_with($table, $prefix)) { $table = substr($table, strlen($prefix)); } @@ -217,7 +217,7 @@ public function dropPrimaryKey(): Table */ public function dropForeignKey(string $foreignName) { - if (empty($this->foreignKeys)) { + if ($this->foreignKeys === []) { return $this; } @@ -230,6 +230,8 @@ public function dropForeignKey(string $foreignName) /** * Adds primary key + * + * @param array{fields?: list} $fields */ public function addPrimaryKey(array $fields): Table { @@ -254,6 +256,8 @@ public function addPrimaryKey(array $fields): Table /** * Add a foreign key * + * @param list, referenceTable: string, referenceField: list, onDelete: string, onUpdate: string, fkName: string}> $foreignKeys + * * @return $this */ public function addForeignKey(array $foreignKeys) @@ -329,9 +333,16 @@ protected function createTable() } foreach ($this->foreignKeys as $foreignKey) { + $foreignTableName = $foreignKey->foreign_table_name; + $prefix = $this->db->DBPrefix; + + if ($prefix !== '' && str_starts_with($foreignTableName, $prefix)) { + $foreignTableName = substr($foreignTableName, strlen($prefix)); + } + $this->forge->addForeignKey( $foreignKey->column_name, - trim($foreignKey->foreign_table_name, $this->db->DBPrefix), + $foreignTableName, $foreignKey->foreign_column_name, ); } @@ -374,9 +385,9 @@ protected function copyData() * Converts fields retrieved from the database to * the format needed for creating fields with Forge. * - * @param array|bool $fields + * @param bool|list $fields * - * @return ($fields is array ? array : mixed) + * @return ($fields is array ? array> : mixed) */ protected function formatFields($fields) { @@ -452,7 +463,7 @@ private function isNumericType(string $type): bool * * @param array $keys * - * @return array + * @return array, type: string}> */ protected function formatKeys($keys) { diff --git a/system/Database/SQLite3/Utils.php b/system/Database/SQLite3/Utils.php index 93f7f0e00db7..c2102e6accab 100644 --- a/system/Database/SQLite3/Utils.php +++ b/system/Database/SQLite3/Utils.php @@ -18,19 +18,17 @@ /** * Utils for SQLite3 + * + * @extends BaseUtils */ class Utils extends BaseUtils { /** - * OPTIMIZE TABLE statement - * - * @var string + * @var bool|string */ protected $optimizeTable = 'REINDEX %s'; /** - * Platform dependent version of the backup function. - * * @return never */ public function _backup(?array $prefs = null) diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php index b9180a98bd59..48fd5b3404c8 100644 --- a/system/Debug/BaseExceptionHandler.php +++ b/system/Debug/BaseExceptionHandler.php @@ -48,9 +48,7 @@ public function __construct(ExceptionsConfig $config) $this->obLevel = ob_get_level(); - if ($this->viewPath === null) { - $this->viewPath = rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; - } + $this->viewPath ??= rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; } /** @@ -70,6 +68,16 @@ abstract public function handle( /** * Gathers the variables that will be made available to the view. + * + * @return array{ + * title: string, + * type: string, + * code: int, + * message: string, + * file: string, + * line: int, + * trace: list>, + * } */ protected function collectVars(Throwable $exception, int $statusCode): array { @@ -99,6 +107,11 @@ protected function collectVars(Throwable $exception, int $statusCode): array /** * Mask sensitive data in the trace. + * + * @param list> $trace + * @param list $keysToMask + * + * @return list> */ protected function maskSensitiveData(array $trace, array $keysToMask, string $path = ''): array { @@ -110,9 +123,10 @@ protected function maskSensitiveData(array $trace, array $keysToMask, string $pa } /** - * @param array|object $args + * @param array|object $args + * @param list $keysToMask * - * @return array|object + * @return array|object */ private function maskData($args, array $keysToMask, string $path = '') { diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 3c8b1f006ea7..74092b627963 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -355,6 +355,8 @@ protected function render(Throwable $exception, int $statusCode) /** * Gathers the variables that will be made available to the view. * + * @return array{title: string, type: string, code: int, message: string, file: string, line: int, trace: list>} + * * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ protected function collectVars(Throwable $exception, int $statusCode): array @@ -386,9 +388,10 @@ protected function collectVars(Throwable $exception, int $statusCode): array /** * Mask sensitive data in the trace. * - * @param array $trace + * @param list> $trace + * @param list $keysToMask * - * @return array + * @return list> * * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ @@ -402,9 +405,10 @@ protected function maskSensitiveData($trace, array $keysToMask, string $path = ' } /** - * @param array|object $args + * @param array|object $args + * @param list $keysToMask * - * @return array|object + * @return array|object * * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ @@ -441,6 +445,8 @@ private function maskData($args, array $keysToMask, string $path = '') /** * Determines the HTTP status code and the exit status code for this request. + * + * @return array{int, int} */ protected function determineCodes(Throwable $exception): array { diff --git a/system/Debug/Iterator.php b/system/Debug/Iterator.php index 55ee0f3bce8b..07a6d6d3689f 100644 --- a/system/Debug/Iterator.php +++ b/system/Debug/Iterator.php @@ -23,14 +23,14 @@ class Iterator /** * Stores the tests that we are to run. * - * @var array + * @var array */ protected $tests = []; /** * Stores the results of each of the tests. * - * @var array + * @var array */ protected $results = []; diff --git a/system/Debug/Timer.php b/system/Debug/Timer.php index 98b1299a89f7..4c383a743413 100644 --- a/system/Debug/Timer.php +++ b/system/Debug/Timer.php @@ -28,7 +28,7 @@ class Timer /** * List of all timers. * - * @var array + * @var array */ protected $timers = []; @@ -46,7 +46,7 @@ class Timer public function start(string $name, ?float $time = null) { $this->timers[strtolower($name)] = [ - 'start' => empty($time) ? microtime(true) : $time, + 'start' => $time ?? microtime(true), 'end' => null, ]; @@ -67,7 +67,7 @@ public function stop(string $name) { $name = strtolower($name); - if (empty($this->timers[$name])) { + if (! isset($this->timers[$name])) { throw new RuntimeException('Cannot stop timer: invalid name given.'); } @@ -90,15 +90,13 @@ public function getElapsedTime(string $name, int $decimals = 4) { $name = strtolower($name); - if (empty($this->timers[$name])) { + if (! isset($this->timers[$name])) { return null; } $timer = $this->timers[$name]; - if (empty($timer['end'])) { - $timer['end'] = microtime(true); - } + $timer['end'] ??= microtime(true); return (float) number_format($timer['end'] - $timer['start'], $decimals, '.', ''); } @@ -107,15 +105,15 @@ public function getElapsedTime(string $name, int $decimals = 4) * Returns the array of timers, with the duration pre-calculated for you. * * @param int $decimals Number of decimal places + * + * @return array */ public function getTimers(int $decimals = 4): array { $timers = $this->timers; foreach ($timers as &$timer) { - if (empty($timer['end'])) { - $timer['end'] = microtime(true); - } + $timer['end'] ??= microtime(true); $timer['duration'] = (float) number_format($timer['end'] - $timer['start'], $decimals); } diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index f5885aa0555c..d38cfcb20e4e 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -190,6 +190,9 @@ public function run(float $startTime, float $totalTime, RequestInterface $reques /** * Called within the view to display the timeline itself. + * + * @param list>}> $collectors + * @param array $styles */ protected function renderTimeline(array $collectors, float $startTime, int $segmentCount, int $segmentDuration, array &$styles): string { @@ -202,6 +205,9 @@ protected function renderTimeline(array $collectors, float $startTime, int $segm /** * Recursively renders timeline elements and their children. + * + * @param list> $rows + * @param array $styles */ protected function renderTimelineRecursive(array $rows, float $startTime, int $segmentCount, int $segmentDuration, array &$styles, int &$styleCount, int $level = 0, bool $isChild = false): string { @@ -210,8 +216,8 @@ protected function renderTimelineRecursive(array $rows, float $startTime, int $s $output = ''; foreach ($rows as $row) { - $hasChildren = isset($row['children']) && ! empty($row['children']); - $isQuery = isset($row['query']) && ! empty($row['query']); + $hasChildren = ($row['children'] ?? []) !== []; + $isQuery = ($row['query'] ?? '') !== ''; // Open controller timeline by default $open = $row['name'] === 'Controller'; @@ -268,7 +274,9 @@ protected function renderTimelineRecursive(array $rows, float $startTime, int $s /** * Returns a sorted array of timeline data arrays from the collectors. * - * @param array $collectors + * @param list>}> $collectors + * + * @return list> */ protected function collectTimelineData($collectors): array { @@ -305,6 +313,10 @@ protected function collectTimelineData($collectors): array /** * Arranges the already sorted timeline data into a parent => child structure. + * + * @param list> $elements + * + * @return list> */ protected function structureTimelineData(array $elements): array { @@ -333,6 +345,8 @@ protected function structureTimelineData(array $elements): array /** * Returns an array of data from all of the modules * that should be displayed in the 'Vars' tab. + * + * @return array */ protected function collectVarData(): array { diff --git a/system/Debug/Toolbar/Collectors/BaseCollector.php b/system/Debug/Toolbar/Collectors/BaseCollector.php index 81cc631f98e7..05fdbf71a95e 100644 --- a/system/Debug/Toolbar/Collectors/BaseCollector.php +++ b/system/Debug/Toolbar/Collectors/BaseCollector.php @@ -105,6 +105,8 @@ public function hasTimelineData(): bool /** * Grabs the data for the timeline, properly formatted, * or returns an empty array. + * + * @return list> */ public function timelineData(): array { @@ -140,7 +142,7 @@ public function hasVarData(): bool * ], * ]; * - * @return array|null + * @return array|null */ public function getVarData() { @@ -159,6 +161,8 @@ public function getVarData() * 'start' => 10 // milliseconds * 'duration' => 15 // milliseconds * ] + * + * @return list> */ protected function formatTimelineData(): array { @@ -168,7 +172,7 @@ protected function formatTimelineData(): array /** * Returns the data of this collector to be formatted in the toolbar * - * @return array|string + * @return array|string */ public function display() { @@ -218,6 +222,20 @@ public function icon(): string /** * Return settings as an array. + * + * @return array{ + * title: string, + * titleSafe: string, + * titleDetails: string, + * display: array|string, + * badgeValue: int|null, + * isEmpty: bool, + * hasTabContent: bool, + * hasLabel: bool, + * icon: string, + * hasTimelineData: bool, + * timelineData: list>, + * } */ public function getAsArray(): array { diff --git a/system/Debug/Toolbar/Collectors/Config.php b/system/Debug/Toolbar/Collectors/Config.php index 80673f979f6c..d806858728dc 100644 --- a/system/Debug/Toolbar/Collectors/Config.php +++ b/system/Debug/Toolbar/Collectors/Config.php @@ -23,6 +23,17 @@ class Config { /** * Return toolbar config values as an array. + * + * @return array{ + * ciVersion: string, + * phpVersion: string, + * phpSAPI: string, + * environment: string, + * baseURL: string, + * timezone: string, + * locale: string, + * cspEnabled: bool, + * } */ public static function display(): array { diff --git a/system/Debug/Toolbar/Collectors/Database.php b/system/Debug/Toolbar/Collectors/Database.php index fdf961d2f974..6ab1094c2918 100644 --- a/system/Debug/Toolbar/Collectors/Database.php +++ b/system/Debug/Toolbar/Collectors/Database.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Debug\Toolbar\Collectors; +use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Query; use CodeIgniter\I18n\Time; use Config\Toolbar; @@ -55,7 +56,7 @@ class Database extends BaseCollector /** * Array of database connections. * - * @var array + * @var array */ protected $connections; @@ -63,7 +64,7 @@ class Database extends BaseCollector * The query instances that have been collected * through the DBQuery Event. * - * @var array + * @var list>}> */ protected static $queries = []; @@ -88,7 +89,7 @@ public static function collect(Query $query) $config = config(Toolbar::class); // Provide default in case it's not set - $max = $config->maxQueries ?: 100; + $max = ($config->maxQueries === 0) ? 100 : $config->maxQueries; if (count(static::$queries) < $max) { $queryString = $query->getQuery(); @@ -113,7 +114,13 @@ public static function collect(Query $query) /** * Returns timeline data formatted for the toolbar. * - * @return array The formatted data or an empty array. + * @return list */ protected function formatTimelineData(): array { @@ -144,6 +151,16 @@ protected function formatTimelineData(): array /** * Returns the data of this collector to be formatted in the toolbar + * + * @return array{queries: list>, + * trace-file: string, + * qid: string + * }>} */ public function display(): array { diff --git a/system/Debug/Toolbar/Collectors/Events.php b/system/Debug/Toolbar/Collectors/Events.php index 173c71a32733..46d20083c0bd 100644 --- a/system/Debug/Toolbar/Collectors/Events.php +++ b/system/Debug/Toolbar/Collectors/Events.php @@ -53,6 +53,8 @@ class Events extends BaseCollector /** * Child classes should implement this to return the timeline data * formatted for correct usage. + * + * @return list */ protected function formatTimelineData(): array { @@ -74,6 +76,8 @@ protected function formatTimelineData(): array /** * Returns the data of this collector to be formatted in the toolbar + * + * @return array{events: array} */ public function display(): array { diff --git a/system/Debug/Toolbar/Collectors/Files.php b/system/Debug/Toolbar/Collectors/Files.php index a573388f960e..35e345c04fc4 100644 --- a/system/Debug/Toolbar/Collectors/Files.php +++ b/system/Debug/Toolbar/Collectors/Files.php @@ -52,6 +52,8 @@ public function getTitleDetails(): string /** * Returns the data of this collector to be formatted in the toolbar + * + * @return array{coreFiles: list, userFiles: list} */ public function display(): array { diff --git a/system/Debug/Toolbar/Collectors/History.php b/system/Debug/Toolbar/Collectors/History.php index 583b350cb4b6..a0575feba9e8 100644 --- a/system/Debug/Toolbar/Collectors/History.php +++ b/system/Debug/Toolbar/Collectors/History.php @@ -55,7 +55,16 @@ class History extends BaseCollector protected $title = 'History'; /** - * @var array History files + * @var list */ protected $files = []; @@ -111,6 +120,17 @@ public function setFiles(string $current, int $limit = 20) /** * Returns the data of this collector to be formatted in the toolbar + * + * @return array{files: list} */ public function display(): array { diff --git a/system/Debug/Toolbar/Collectors/Timers.php b/system/Debug/Toolbar/Collectors/Timers.php index d450cc5c306a..9e86af2c8d9d 100644 --- a/system/Debug/Toolbar/Collectors/Timers.php +++ b/system/Debug/Toolbar/Collectors/Timers.php @@ -45,6 +45,8 @@ class Timers extends BaseCollector /** * Child classes should implement this to return the timeline data * formatted for correct usage. + * + * @return list */ protected function formatTimelineData(): array { diff --git a/system/Debug/Toolbar/Collectors/Views.php b/system/Debug/Toolbar/Collectors/Views.php index a63e172c3b8a..bae742a588a2 100644 --- a/system/Debug/Toolbar/Collectors/Views.php +++ b/system/Debug/Toolbar/Collectors/Views.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Debug\Toolbar\Collectors; use CodeIgniter\View\RendererInterface; +use CodeIgniter\View\View; /** * Views collector @@ -70,10 +71,15 @@ class Views extends BaseCollector /** * Views counter * - * @var array + * @var array{} + * + * @deprecated 4.7.5 Unused. */ protected $views = []; + /** + * @phpstan-assert View $this->viewer + */ private function initViewer(): void { $this->viewer ??= service('renderer'); @@ -82,6 +88,8 @@ private function initViewer(): void /** * Child classes should implement this to return the timeline data * formatted for correct usage. + * + * @return list */ protected function formatTimelineData(): array { @@ -118,6 +126,8 @@ protected function formatTimelineData(): array * 'bar' => 'baz' * ], * ]; + * + * @return array> */ public function getVarData(): array { diff --git a/system/Email/Email.php b/system/Email/Email.php index 4b3e94dfb38b..9304734fd179 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -30,14 +30,14 @@ class Email /** * Properties from the last successful send. * - * @var array|null + * @var array|null */ public $archive; /** * Properties to be added to the next archive. * - * @var array + * @var array|string> */ protected $tmpArchive = []; @@ -298,7 +298,7 @@ class Email * * @see Email::printDebugger() * - * @var array + * @var list */ protected $debugMessage = []; @@ -312,35 +312,42 @@ class Email /** * Recipients * - * @var array|string + * @var list */ protected $recipients = []; /** * CC Recipients * - * @var array + * @var list */ protected $CCArray = []; /** * BCC Recipients * - * @var array + * @var list */ protected $BCCArray = []; /** * Message headers * - * @var array + * @var array */ protected $headers = []; /** * Attachment data * - * @var array + * @var list */ protected $attachments = []; @@ -405,21 +412,19 @@ class Email protected static $func_overload; /** - * @param array|\Config\Email|null $config + * @param array|\Config\Email|null $config */ public function __construct($config = null) { $this->initialize($config); - if (! isset(static::$func_overload)) { - static::$func_overload = extension_loaded('mbstring') && ini_get('mbstring.func_overload'); - } + static::$func_overload ??= extension_loaded('mbstring') && ini_get('mbstring.func_overload'); } /** * Initialize preferences * - * @param array|\Config\Email|null $config + * @param array|\Config\Email|null $config * * @return $this */ @@ -554,7 +559,7 @@ public function setReplyTo($replyto, $name = '') } /** - * @param array|string $to + * @param list|string $to * * @return $this */ @@ -693,7 +698,7 @@ public function attach($file, $disposition = '', $newname = null, $mime = '') $this->attachments[] = [ 'name' => $namesAttached, - 'disposition' => empty($disposition) ? 'attachment' : $disposition, + 'disposition' => $disposition === '' ? 'attachment' : $disposition, // Can also be 'inline' Not sure if it matters 'type' => $mime, 'content' => chunk_split(base64_encode($fileContent)), @@ -933,7 +938,7 @@ protected function getMimeMessage() } /** - * @param array|string $email + * @param list|string $email * * @return bool */ @@ -972,9 +977,9 @@ public function isValidEmail($email) } /** - * @param array|string $email + * @param list|string $email * - * @return array|string + * @return ($email is array ? list : string) */ public function cleanEmail($email) { @@ -1454,10 +1459,7 @@ protected function prepQEncoding($str) } } - // We might already have this set for UTF-8 - if (! isset($chars)) { - $chars = static::strlen($str); - } + $chars ??= static::strlen($str); $output = '=?' . $this->charset . '?Q?'; @@ -1488,7 +1490,7 @@ protected function prepQEncoding($str) */ public function send($autoClear = true) { - if (! isset($this->headers['From']) && ! empty($this->fromEmail)) { + if (! isset($this->headers['From']) && $this->fromEmail !== null && $this->fromEmail !== '') { $this->setFrom($this->fromEmail, $this->fromName); } @@ -1503,8 +1505,8 @@ public function send($autoClear = true) } if ( - empty($this->recipients) && ! isset($this->headers['To']) - && empty($this->BCCArray) && ! isset($this->headers['Bcc']) + $this->recipients === [] && ! isset($this->headers['To']) + && $this->BCCArray === [] && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) ) { $this->setErrorMessage(lang('Email.noRecipients')); @@ -2121,8 +2123,8 @@ protected function getHostname() } /** - * @param array|string $include List of raw data chunks to include in the output - * Valid options are: 'headers', 'subject', 'body' + * @param list|string $include List of raw data chunks to include in the output + * Valid options are: 'headers', 'subject', 'body' * * @return string */ @@ -2180,7 +2182,7 @@ protected function mimeTypes($ext = '') { $mime = Mimes::guessTypeFromExtension(strtolower($ext)); - return empty($mime) ? 'application/x-unknown-content-type' : $mime; + return $mime ?? 'application/x-unknown-content-type'; } public function __destruct() @@ -2229,7 +2231,7 @@ protected static function substr($str, $start, $length = null) /** * Determines the values that should be stored in $archive. * - * @return array The updated archive values + * @return array The updated archive values */ protected function setArchiveValues(): array { diff --git a/system/Encryption/EncrypterInterface.php b/system/Encryption/EncrypterInterface.php index 9c1b0d36d1be..1c916e8b390c 100644 --- a/system/Encryption/EncrypterInterface.php +++ b/system/Encryption/EncrypterInterface.php @@ -26,8 +26,8 @@ interface EncrypterInterface /** * Encrypt - convert plaintext into ciphertext * - * @param string $data Input data - * @param array|string|null $params Overridden parameters, specifically the key + * @param string $data Input data + * @param array|string|null $params Overridden parameters, specifically the key * * @return string * @@ -38,8 +38,8 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para /** * Decrypt - convert ciphertext into plaintext * - * @param string $data Encrypted data - * @param array|string|null $params Overridden parameters, specifically the key + * @param string $data Encrypted data + * @param array|string|null $params Overridden parameters, specifically the key * * @return string * diff --git a/system/Encryption/Encryption.php b/system/Encryption/Encryption.php index 73ec458b837a..d24a6754efab 100644 --- a/system/Encryption/Encryption.php +++ b/system/Encryption/Encryption.php @@ -70,7 +70,7 @@ class Encryption /** * Map of drivers to handler classes, in preference order * - * @var array + * @var list */ protected $drivers = [ 'OpenSSL', @@ -121,7 +121,7 @@ public function initialize(?EncryptionConfig $config = null) $this->digest = $config->digest; } - if (empty($this->driver)) { + if ($this->driver === null || $this->driver === '') { throw EncryptionException::forNoDriverRequested(); } @@ -129,7 +129,7 @@ public function initialize(?EncryptionConfig $config = null) throw EncryptionException::forUnKnownHandler($this->driver); } - if (empty($this->key)) { + if (in_array($this->key, [null, '', '0'], true)) { throw EncryptionException::forNeedsStarterKey(); } @@ -138,8 +138,14 @@ public function initialize(?EncryptionConfig $config = null) $handlerName = 'CodeIgniter\\Encryption\\Handlers\\' . $this->driver . 'Handler'; $this->encrypter = new $handlerName($config); - if (($config->previousKeys ?? []) !== []) { - $this->encrypter = new KeyRotationDecorator($this->encrypter, $config->previousKeys); + // (array) '' is [''], not [], so the unset default must be filtered out here. + $previousKeys = array_values(array_filter( + (array) ($config->previousKeys ?? []), + static fn ($key): bool => $key !== '', + )); + + if ($previousKeys !== []) { + $this->encrypter = new KeyRotationDecorator($this->encrypter, $previousKeys); } return $this->encrypter; @@ -162,7 +168,7 @@ public static function createKey($length = 32) * * @param string $key Property name * - * @return array|string|null + * @return list|string|null */ public function __get($key) { diff --git a/system/Encryption/Handlers/BaseHandler.php b/system/Encryption/Handlers/BaseHandler.php index c324a465dbb8..0f0627c654d6 100644 --- a/system/Encryption/Handlers/BaseHandler.php +++ b/system/Encryption/Handlers/BaseHandler.php @@ -55,7 +55,7 @@ protected static function substr($str, $start, $length = null) * * @param string $key Property name * - * @return array|bool|int|string|null + * @return array|bool|int|string|null */ public function __get($key) { diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 8e1be06d60ed..d649a66bf060 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -19,6 +19,9 @@ /** * Encryption handling for OpenSSL library * + * @property-read string $cipher + * @property-read string $key + * * @see \CodeIgniter\Encryption\Handlers\OpenSSLHandlerTest */ class OpenSSLHandler extends BaseHandler @@ -33,7 +36,7 @@ class OpenSSLHandler extends BaseHandler /** * List of supported HMAC algorithms * - * @var array [name => digest size] + * @var array [name => digest size] */ protected array $digestSize = [ 'SHA224' => 28, @@ -87,7 +90,7 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) : $this->key; - if (empty($key)) { + if (! is_string($key) || in_array($key, ['', '0'], true)) { throw EncryptionException::forNeedsStarterKey(); } @@ -123,7 +126,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) : $this->key; - if (empty($key)) { + if (! is_string($key) || in_array($key, ['', '0'], true)) { throw EncryptionException::forNeedsStarterKey(); } diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index 55bf14893251..0fa1bd235247 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -20,6 +20,9 @@ /** * SodiumHandler uses libsodium in encryption. * + * @property-read int $blockSize + * @property-read string|null $key + * * @see https://github.com/jedisct1/libsodium/issues/392 * @see \CodeIgniter\Encryption\Handlers\SodiumHandlerTest */ @@ -56,7 +59,7 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para } } - if (empty($key) || strlen((string) $key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + if (! is_string($key) || strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { throw EncryptionException::forNeedsStarterKey(); } @@ -92,7 +95,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) } } - if (empty($key) || strlen((string) $key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + if (! is_string($key) || strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { throw EncryptionException::forNeedsStarterKey(); } @@ -124,7 +127,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) /** * Parse the $params before doing assignment. * - * @param array|string|null $params + * @param array|string|null $params * * @return void * diff --git a/system/Encryption/KeyRotationDecorator.php b/system/Encryption/KeyRotationDecorator.php index 9e4b6ef0002b..46846dead1bb 100644 --- a/system/Encryption/KeyRotationDecorator.php +++ b/system/Encryption/KeyRotationDecorator.php @@ -22,6 +22,9 @@ * Wraps any EncrypterInterface implementation to provide automatic * fallback to previous encryption keys during decryption. This enables * seamless key rotation without requiring re-encryption of existing data. + * + * @property-read string|null $cipher + * @property-read string|null $key */ class KeyRotationDecorator implements EncrypterInterface { @@ -86,7 +89,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) /** * Delegate property access to the inner handler. * - * @return array|bool|int|string|null + * @return array|bool|int|string|null */ public function __get(string $key) { diff --git a/system/Entity/Cast/ArrayCast.php b/system/Entity/Cast/ArrayCast.php index 6860b41416b6..dc1d5d7e20d2 100644 --- a/system/Entity/Cast/ArrayCast.php +++ b/system/Entity/Cast/ArrayCast.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Entity\Cast; +/** + * @extends BaseCast, string> + */ class ArrayCast extends BaseCast { public static function get($value, array $params = []): array diff --git a/system/Entity/Cast/BaseCast.php b/system/Entity/Cast/BaseCast.php index c93e32c0537c..68bd544a071f 100644 --- a/system/Entity/Cast/BaseCast.php +++ b/system/Entity/Cast/BaseCast.php @@ -13,6 +13,12 @@ namespace CodeIgniter\Entity\Cast; +/** + * @template TPhpNativeValue + * @template TEntityStoredValue + * + * @implements CastInterface + */ abstract class BaseCast implements CastInterface { public static function get($value, array $params = []) diff --git a/system/Entity/Cast/BooleanCast.php b/system/Entity/Cast/BooleanCast.php index 7ac9f0ba37ee..a8bdb8ac1e7d 100644 --- a/system/Entity/Cast/BooleanCast.php +++ b/system/Entity/Cast/BooleanCast.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Entity\Cast; +/** + * @extends BaseCast + */ class BooleanCast extends BaseCast { public static function get($value, array $params = []): bool diff --git a/system/Entity/Cast/CSVCast.php b/system/Entity/Cast/CSVCast.php index c78a1315111a..6c4a65f816ab 100644 --- a/system/Entity/Cast/CSVCast.php +++ b/system/Entity/Cast/CSVCast.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Entity\Cast; +/** + * @extends BaseCast, string> + */ class CSVCast extends BaseCast { public static function get($value, array $params = []): array diff --git a/system/Entity/Cast/CastInterface.php b/system/Entity/Cast/CastInterface.php index 9f5552826859..31b85f4ef4e9 100644 --- a/system/Entity/Cast/CastInterface.php +++ b/system/Entity/Cast/CastInterface.php @@ -17,26 +17,29 @@ * The methods work at (1)(4) only. * [App Code] --- (1) --> [Entity] --- (2) --> [Database] * [App Code] <-- (4) --- [Entity] <-- (3) --- [Database] + * + * @template TPhpNativeValue + * @template TEntityStoredValue */ interface CastInterface { /** * Takes a raw value from Entity, returns its value for PHP. * - * @param mixed $value Data + * @param TEntityStoredValue $value * @param array $params Additional param * - * @return mixed + * @return TPhpNativeValue */ public static function get($value, array $params = []); /** * Takes a PHP value, returns its raw value for Entity. * - * @param mixed $value Data + * @param TPhpNativeValue $value * @param array $params Additional param * - * @return mixed + * @return TEntityStoredValue */ public static function set($value, array $params = []); } diff --git a/system/Entity/Cast/DatetimeCast.php b/system/Entity/Cast/DatetimeCast.php index e49df121987e..332de01a99cf 100644 --- a/system/Entity/Cast/DatetimeCast.php +++ b/system/Entity/Cast/DatetimeCast.php @@ -15,17 +15,12 @@ use CodeIgniter\I18n\Time; use DateTimeInterface; -use Exception; +/** + * @extends BaseCast + */ class DatetimeCast extends BaseCast { - /** - * {@inheritDoc} - * - * @return Time - * - * @throws Exception - */ public static function get($value, array $params = []) { if ($value instanceof Time) { diff --git a/system/Entity/Cast/EnumCast.php b/system/Entity/Cast/EnumCast.php index 7ea82d6f24a5..caf6b1289294 100644 --- a/system/Entity/Cast/EnumCast.php +++ b/system/Entity/Cast/EnumCast.php @@ -18,6 +18,9 @@ use ReflectionEnum; use UnitEnum; +/** + * @extends BaseCast + */ class EnumCast extends BaseCast { public static function get($value, array $params = []): BackedEnum|UnitEnum @@ -34,11 +37,9 @@ public static function get($value, array $params = []): BackedEnum|UnitEnum $reflection = new ReflectionEnum($enumClass); - // Backed enum - validate and cast the value to proper type - if ($reflection->isBacked()) { + if (is_a($enumClass, BackedEnum::class, true)) { $backingType = $reflection->getBackingType(); - // Cast to proper type (int or string) if ($backingType->getName() === 'int') { $value = (int) $value; } elseif ($backingType->getName() === 'string') { @@ -54,7 +55,6 @@ public static function get($value, array $params = []): BackedEnum|UnitEnum return $enum; } - // Unit enum - match by name foreach ($enumClass::cases() as $case) { if ($case->name === $value) { return $case; @@ -64,9 +64,11 @@ public static function get($value, array $params = []): BackedEnum|UnitEnum throw CastException::forInvalidEnumCaseName($enumClass, $value); } + /** + * @param mixed $value + */ public static function set($value, array $params = []): int|string { - // Get the expected enum class $enumClass = $params[0] ?? null; if ($enumClass === null) { @@ -77,33 +79,23 @@ public static function set($value, array $params = []): int|string throw CastException::forNotEnum($enumClass); } - // If it's already an enum object, validate and extract its value if (is_object($value) && enum_exists($value::class)) { - // Validate that the enum is of the expected type if (! $value instanceof $enumClass) { throw CastException::forInvalidEnumType($enumClass, $value::class); } - $reflection = new ReflectionEnum($value::class); - - // Backed enum - return the properly typed backing value - if ($reflection->isBacked()) { - /** @var BackedEnum $value */ + if ($value instanceof BackedEnum) { return $value->value; } - // Unit enum - return the case name - /** @var UnitEnum $value */ return $value->name; } $reflection = new ReflectionEnum($enumClass); - // Validate backed enum values - if ($reflection->isBacked()) { + if (is_a($enumClass, BackedEnum::class, true)) { $backingType = $reflection->getBackingType(); - // Cast to proper type (int or string) if ($backingType->getName() === 'int') { $value = (int) $value; } elseif ($backingType->getName() === 'string') { @@ -117,7 +109,6 @@ public static function set($value, array $params = []): int|string return $value; } - // Validate unit enum case names - must be a string $value = (string) $value; foreach ($enumClass::cases() as $case) { diff --git a/system/Entity/Cast/FloatCast.php b/system/Entity/Cast/FloatCast.php index 1a767c0953f0..f629fe54016e 100644 --- a/system/Entity/Cast/FloatCast.php +++ b/system/Entity/Cast/FloatCast.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Entity\Cast; +/** + * @extends BaseCast + */ class FloatCast extends BaseCast { public static function get($value, array $params = []): float diff --git a/system/Entity/Cast/IntBoolCast.php b/system/Entity/Cast/IntBoolCast.php index 94e65df8dde0..21e03c04a98a 100644 --- a/system/Entity/Cast/IntBoolCast.php +++ b/system/Entity/Cast/IntBoolCast.php @@ -15,20 +15,16 @@ /** * DB column: int (0/1) <--> Class property: bool + * + * @extends BaseCast */ final class IntBoolCast extends BaseCast { - /** - * @param int $value - */ public static function get($value, array $params = []): bool { return (bool) $value; } - /** - * @param bool|int|string $value - */ public static function set($value, array $params = []): int { return (int) $value; diff --git a/system/Entity/Cast/IntegerCast.php b/system/Entity/Cast/IntegerCast.php index 84bf09211c0f..dad299ef74c1 100644 --- a/system/Entity/Cast/IntegerCast.php +++ b/system/Entity/Cast/IntegerCast.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Entity\Cast; +/** + * @extends BaseCast + */ class IntegerCast extends BaseCast { public static function get($value, array $params = []): int diff --git a/system/Entity/Cast/JsonCast.php b/system/Entity/Cast/JsonCast.php index 245de29f3956..c94e4d6f34a8 100644 --- a/system/Entity/Cast/JsonCast.php +++ b/system/Entity/Cast/JsonCast.php @@ -17,6 +17,9 @@ use JsonException; use stdClass; +/** + * @extends BaseCast + */ class JsonCast extends BaseCast { public static function get($value, array $params = []) @@ -43,9 +46,6 @@ public static function get($value, array $params = []) return $tmp; } - /** - * {@inheritDoc} - */ public static function set($value, array $params = []): string { if (function_exists('json_encode')) { diff --git a/system/Entity/Cast/ObjectCast.php b/system/Entity/Cast/ObjectCast.php index 10affad4c80c..c2c1499a3af0 100644 --- a/system/Entity/Cast/ObjectCast.php +++ b/system/Entity/Cast/ObjectCast.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Entity\Cast; +/** + * @extends BaseCast + */ class ObjectCast extends BaseCast { public static function get($value, array $params = []): object diff --git a/system/Entity/Cast/StringCast.php b/system/Entity/Cast/StringCast.php index d538f4332dca..7c01c2c4a617 100644 --- a/system/Entity/Cast/StringCast.php +++ b/system/Entity/Cast/StringCast.php @@ -13,6 +13,9 @@ namespace CodeIgniter\Entity\Cast; +/** + * @extends BaseCast + */ class StringCast extends BaseCast { public static function get($value, array $params = []): string diff --git a/system/Entity/Cast/TimestampCast.php b/system/Entity/Cast/TimestampCast.php index 44bdf78864d1..b48211f221df 100644 --- a/system/Entity/Cast/TimestampCast.php +++ b/system/Entity/Cast/TimestampCast.php @@ -15,6 +15,9 @@ use CodeIgniter\Entity\Exceptions\CastException; +/** + * @extends BaseCast + */ class TimestampCast extends BaseCast { public static function get($value, array $params = []) diff --git a/system/Entity/Cast/URICast.php b/system/Entity/Cast/URICast.php index a66bfba3ef45..ef4346d1a5eb 100644 --- a/system/Entity/Cast/URICast.php +++ b/system/Entity/Cast/URICast.php @@ -15,6 +15,9 @@ use CodeIgniter\HTTP\URI; +/** + * @extends BaseCast + */ class URICast extends BaseCast { public static function get($value, array $params = []): URI diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index 95261f607458..a67c44b6fd1c 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -421,7 +421,7 @@ public function hasChanged(?string $key = null): bool * Checks if an array contains only Entity instances. * This allows optimization for per-entity change tracking. * - * @param array $data + * @param array $data */ private function containsOnlyEntities(array $data): bool { diff --git a/system/Exceptions/PageNotFoundException.php b/system/Exceptions/PageNotFoundException.php index f84763ec25ff..f3a69745adc9 100644 --- a/system/Exceptions/PageNotFoundException.php +++ b/system/Exceptions/PageNotFoundException.php @@ -72,6 +72,8 @@ public static function forLocaleNotSupported(string $locale) * have the current locale, so even if users call * `$this->request->setLocale()` in the controller afterwards, * the Language locale will not be changed. + * + * @param array $args */ private static function lang(string $line, array $args = []): string { diff --git a/system/Files/File.php b/system/Files/File.php index 83a304f26e99..2cf394355185 100644 --- a/system/Files/File.php +++ b/system/Files/File.php @@ -30,7 +30,7 @@ class File extends SplFileInfo /** * The files size in bytes * - * @var int + * @var false|int|null */ protected $size; @@ -65,7 +65,7 @@ public function __construct(string $path, bool $checkFile = false) */ public function getSize(): false|int { - return $this->size ?? ($this->size = parent::getSize()); + return $this->size ??= parent::getSize(); } /** @@ -111,7 +111,8 @@ public function getSizeByUnit(string $unit = 'b') public function guessExtension(): ?string { // naively get the path extension using pathinfo - $pathinfo = pathinfo($this->getRealPath() ?: $this->__toString()) + ['extension' => '']; + $realPath = $this->getRealPath(); + $pathinfo = pathinfo($realPath === false ? $this->__toString() : $realPath) + ['extension' => '']; $proposedExtension = $pathinfo['extension']; @@ -131,9 +132,10 @@ public function getMimeType(): string return $this->originalMimeType ?? 'application/octet-stream'; // @codeCoverageIgnore } - $finfo = finfo_open(FILEINFO_MIME_TYPE); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $realPath = $this->getRealPath(); - return finfo_file($finfo, $this->getRealPath() ?: $this->__toString()); + return finfo_file($finfo, $realPath === false ? $this->__toString() : $realPath); } /** @@ -143,7 +145,7 @@ public function getMimeType(): string public function getRandomName(): string { $extension = $this->getExtension(); - $extension = empty($extension) ? '' : '.' . $extension; + $extension = $extension === '' ? '' : '.' . $extension; return Time::now()->getTimestamp() . '_' . bin2hex(random_bytes(10)) . $extension; } @@ -159,7 +161,8 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite = $name ??= $this->getBasename(); $destination = $overwrite ? $targetPath . $name : $this->getDestination($targetPath . $name); - $oldName = $this->getRealPath() ?: $this->__toString(); + $realPath = $this->getRealPath(); + $oldName = $realPath === false ? $this->__toString() : $realPath; if (! @rename($oldName, $destination)) { $error = error_get_last(); @@ -167,7 +170,7 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite = throw FileException::forUnableToMove($this->getBasename(), $targetPath, strip_tags($error['message'])); } - @chmod($destination, 0777 & ~umask()); + @chmod($destination, 0666 & ~umask()); return new self($destination); } @@ -215,7 +218,7 @@ private function getSizeByUnitInternal(int $fileSizeBase, FileSizeUnit $unit, in $size = $this->getSize() / $divider; if ($unit !== FileSizeUnit::B) { - $size = number_format($size, $precision); + return number_format($size, $precision); } return $size; diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php index b293b4d3c08d..b69cfb1d266b 100644 --- a/system/Files/FileCollection.php +++ b/system/Files/FileCollection.php @@ -17,8 +17,8 @@ use CodeIgniter\Files\Exceptions\FileException; use CodeIgniter\Files\Exceptions\FileNotFoundException; use Countable; -use Generator; use IteratorAggregate; +use Traversable; /** * File Collection Class @@ -393,13 +393,12 @@ public function count(): int /** * Yields as an Iterator for the current files. - * Fulfills IteratorAggregate. * - * @return Generator + * @return Traversable * * @throws FileNotFoundException */ - public function getIterator(): Generator + public function getIterator(): Traversable { foreach ($this->get() as $file) { yield new File($file, true); diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index bf5419eab560..36725b8198d3 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -254,7 +254,7 @@ private function runBefore(array $filterClassList) } // Ignore an empty result - if (empty($result)) { + if (! is_string($result) || $result === '') { continue; } @@ -369,6 +369,8 @@ public function runRequired(string $position = 'before') * * @param 'after'|'before' $position * + * @return array{list, array>} + * * @internal */ public function getRequiredFilters(string $position = 'before'): array @@ -410,7 +412,9 @@ public function getRequiredFilters(string $position = 'before'): array * Set the toolbar filter to the last position to be executed. * * @param list $filters `after` filter array - * @param bool $remove if true, remove `toolbar` filter + * @param bool $remove If true, remove `toolbar` filter + * + * @return list */ private function setToolbarToLast(array $filters, bool $remove = false): array { @@ -552,13 +556,9 @@ public function addFilter(string $class, ?string $alias = null, string $position { $alias ??= md5($class); - if (! isset($this->config->{$section})) { - $this->config->{$section} = []; - } + $this->config->{$section} ??= []; - if (! isset($this->config->{$section}[$position])) { - $this->config->{$section}[$position] = []; - } + $this->config->{$section}[$position] ??= []; $this->config->aliases[$alias] = $class; @@ -853,8 +853,8 @@ protected function processAliasesToClass(string $position) /** * Check paths for match for URI * - * @param string $uri URI to test against - * @param array|string $paths The path patterns to test + * @param string $uri URI to test against + * @param array|string $paths The path patterns to test * * @return bool True if any of the paths apply to the URI */ @@ -876,8 +876,8 @@ private function pathApplies(string $uri, $paths) /** * Check except paths * - * @param string $uri URI path relative to baseURL (all lowercase) - * @param array|string $paths The except path patterns + * @param string $uri URI path relative to baseURL (all lowercase) + * @param array|string $paths The except path patterns * * @return bool True if the URI matches except paths. */ @@ -899,8 +899,8 @@ private function checkExcept(string $uri, $paths): bool /** * Check the URI path as pseudo-regex * - * @param string $uri URI path relative to baseURL (all lowercase, URL-decoded) - * @param array $paths The except path patterns + * @param string $uri URI path relative to baseURL (all lowercase, URL-decoded) + * @param array $paths The except path patterns */ private function checkPseudoRegex(string $uri, array $paths): bool { diff --git a/system/Filters/ForceHTTPS.php b/system/Filters/ForceHTTPS.php index aa9cb3716d08..6f59338d948f 100644 --- a/system/Filters/ForceHTTPS.php +++ b/system/Filters/ForceHTTPS.php @@ -30,7 +30,7 @@ class ForceHTTPS implements FilterInterface * as set the HTTP Strict Transport Security (HSTS) header for those browsers * that support it. * - * @param array|null $arguments + * @param list|null $arguments * * @return ResponseInterface|null */ @@ -56,7 +56,7 @@ public function before(RequestInterface $request, $arguments = null) /** * We don't have anything to do here. * - * @param array|null $arguments + * @param list|null $arguments */ public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { diff --git a/system/Filters/InvalidChars.php b/system/Filters/InvalidChars.php index 85e71c5d4f59..5d714b0fdf11 100644 --- a/system/Filters/InvalidChars.php +++ b/system/Filters/InvalidChars.php @@ -84,9 +84,9 @@ public function after(RequestInterface $request, ResponseInterface $response, $a /** * Check the character encoding is valid UTF-8. * - * @param array|string $value + * @param array|string $value * - * @return array|string + * @return array|string * * @throws SecurityException */ @@ -113,9 +113,9 @@ protected function checkEncoding($value) /** * Check for the presence of control characters except line breaks and tabs. * - * @param array|string $value + * @param array|string $value * - * @return array|string + * @return array|string */ protected function checkControl($value) { diff --git a/system/Filters/PageCache.php b/system/Filters/PageCache.php index ff4bd097f84c..d292ff9875c9 100644 --- a/system/Filters/PageCache.php +++ b/system/Filters/PageCache.php @@ -45,7 +45,7 @@ public function __construct(?Cache $config = null) /** * Checks page cache and return if found. * - * @param array|null $arguments + * @param list|null $arguments * * @return ResponseInterface|null */ @@ -61,7 +61,7 @@ public function before(RequestInterface $request, $arguments = null) /** * Cache the page. * - * @param array|null $arguments + * @param list|null $arguments */ public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { diff --git a/system/Filters/PerformanceMetrics.php b/system/Filters/PerformanceMetrics.php index 97582ad8dc05..259f7d46e298 100644 --- a/system/Filters/PerformanceMetrics.php +++ b/system/Filters/PerformanceMetrics.php @@ -24,7 +24,7 @@ class PerformanceMetrics implements FilterInterface /** * We don't need to do anything here. * - * @param array|null $arguments + * @param list|null $arguments */ public function before(RequestInterface $request, $arguments = null) { @@ -34,7 +34,7 @@ public function before(RequestInterface $request, $arguments = null) /** * Replaces the performance metrics. * - * @param array|null $arguments + * @param list|null $arguments */ public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { diff --git a/system/Format/FormatterInterface.php b/system/Format/FormatterInterface.php index 0c2492b90a77..5f6d6df3870a 100644 --- a/system/Format/FormatterInterface.php +++ b/system/Format/FormatterInterface.php @@ -21,7 +21,7 @@ interface FormatterInterface /** * Takes the given data and formats it. * - * @param array|object|string $data + * @param mixed $data * * @return false|non-empty-string */ diff --git a/system/Format/JSONFormatter.php b/system/Format/JSONFormatter.php index ac237fcc7af3..fb882a0e4eac 100644 --- a/system/Format/JSONFormatter.php +++ b/system/Format/JSONFormatter.php @@ -26,7 +26,7 @@ class JSONFormatter implements FormatterInterface /** * Takes the given data and formats it. * - * @param array|object|string $data + * @param mixed $data * * @return false|non-empty-string */ diff --git a/system/Format/XMLFormatter.php b/system/Format/XMLFormatter.php index efef70cec2cb..0f058e8a7ff7 100644 --- a/system/Format/XMLFormatter.php +++ b/system/Format/XMLFormatter.php @@ -27,7 +27,7 @@ class XMLFormatter implements FormatterInterface /** * Takes the given data and formats it. * - * @param array|object|string $data + * @param mixed $data * * @return false|non-empty-string */ diff --git a/system/HTTP/CLIRequest.php b/system/HTTP/CLIRequest.php index 453272a4f59a..55c8b797b094 100644 --- a/system/HTTP/CLIRequest.php +++ b/system/HTTP/CLIRequest.php @@ -35,21 +35,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 */ protected $options = []; /** * Command line arguments (segments and options). * - * @var array + * @var array */ protected $args = []; @@ -101,6 +101,8 @@ public function getPath(): string /** * Returns an associative array of all CLI options found, with * their values. + * + * @return array */ public function getOptions(): array { @@ -109,6 +111,8 @@ public function getOptions(): array /** * Returns an array of all CLI arguments (segments and options). + * + * @return array */ public function getArgs(): array { @@ -117,6 +121,8 @@ public function getArgs(): array /** * Returns the path segments. + * + * @return list */ public function getSegments(): array { @@ -185,7 +191,7 @@ public function getOptionString(bool $useLongOpts = false): string */ protected function parseCommand() { - $args = $this->getServer('argv'); + $args = $this->getServer('argv') ?? []; array_shift($args); // Scrap index.php $optionValue = false; @@ -226,11 +232,11 @@ public function isCLI(): bool /** * Fetch an item from GET data. * - * @param array|string|null $index Index for item to fetch from $_GET. - * @param int|null $filter A filter name to apply. - * @param array|int|null $flags + * @param list|string|null $index Index for item to fetch from $_GET. + * @param int|null $filter A filter name to apply. + * @param array|int|null $flags * - * @return array|null + * @return ($index is string ? null : array{}) */ public function getGet($index = null, $filter = null, $flags = null) { @@ -240,11 +246,11 @@ public function getGet($index = null, $filter = null, $flags = null) /** * Fetch an item from POST. * - * @param array|string|null $index Index for item to fetch from $_POST. - * @param int|null $filter A filter name to apply - * @param array|int|null $flags + * @param list|string|null $index Index for item to fetch from $_POST. + * @param int|null $filter A filter name to apply + * @param array|int|null $flags * - * @return array|null + * @return ($index is string ? null : array{}) */ public function getPost($index = null, $filter = null, $flags = null) { @@ -254,11 +260,11 @@ public function getPost($index = null, $filter = null, $flags = null) /** * Fetch an item from POST data with fallback to GET. * - * @param array|string|null $index Index for item to fetch from $_POST or $_GET - * @param int|null $filter A filter name to apply - * @param array|int|null $flags + * @param list|string|null $index Index for item to fetch from $_POST or $_GET + * @param int|null $filter A filter name to apply + * @param array|int|null $flags * - * @return array|null + * @return ($index is string ? null : array{}) */ public function getPostGet($index = null, $filter = null, $flags = null) { @@ -268,11 +274,11 @@ public function getPostGet($index = null, $filter = null, $flags = null) /** * Fetch an item from GET data with fallback to POST. * - * @param array|string|null $index Index for item to be fetched from $_GET or $_POST - * @param int|null $filter A filter name to apply - * @param array|int|null $flags + * @param list|string|null $index Index for item to be fetched from $_GET or $_POST + * @param int|null $filter A filter name to apply + * @param array|int|null $flags * - * @return array|null + * @return ($index is string ? null : array{}) */ public function getGetPost($index = null, $filter = null, $flags = null) { @@ -282,11 +288,11 @@ public function getGetPost($index = null, $filter = null, $flags = null) /** * This is a place holder for calls from cookie_helper get_cookie(). * - * @param array|string|null $index Index for item to be fetched from $_COOKIE - * @param int|null $filter A filter name to be applied - * @param mixed $flags + * @param list|string|null $index Index for item to be fetched from $_COOKIE + * @param int|null $filter A filter name to be applied + * @param mixed $flags * - * @return array|null + * @return ($index is string ? null : array{}) */ public function getCookie($index = null, $filter = null, $flags = null) { @@ -294,9 +300,9 @@ public function getCookie($index = null, $filter = null, $flags = null) } /** - * @param array|string|null $index + * @param list|string|null $index * - * @return array|null + * @return ($index is string ? null : array{}) */ private function returnNullOrEmptyArray($index) { diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index ac05f9ae903b..9c2184ac84c1 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -48,17 +48,22 @@ class CURLRequest extends OutgoingRequest */ protected $baseURI; + /** + * The constructor's base URI, preserved independently of per-request changes. + */ + private readonly URI $defaultBaseURI; + /** * The setting values * - * @var array + * @var array */ protected $config; /** * The default setting values * - * @var array + * @var array */ protected $defaultConfig = [ 'timeout' => 0.0, @@ -71,7 +76,7 @@ class CURLRequest extends OutgoingRequest * Default values for when 'allow_redirects' * option is true. * - * @var array + * @var array */ protected $redirectDefaults = [ 'max' => 5, @@ -92,6 +97,8 @@ class CURLRequest extends OutgoingRequest /** * The default options from the constructor. Applied to all requests. + * + * @var array */ private readonly array $defaultOptions; @@ -136,6 +143,7 @@ public function __construct(App $config, URI $uri, ?ResponseInterface $response $this->config = $this->defaultConfig; $this->parseOptions($options); + $this->defaultBaseURI = clone $this->baseURI; // Share Connection $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ // @phpstan-ignore nullCoalesce.property @@ -156,22 +164,25 @@ public function __construct(App $config, URI $uri, ?ResponseInterface $response * Sends an HTTP request to the specified $url. If this is a relative * URL, it will be merged with $this->baseURI to form a complete URL. * - * @param string $method HTTP method + * @param string $method HTTP method + * @param array $options */ public function request($method, string $url, array $options = []): ResponseInterface { $this->response = clone $this->responseOrig; - $this->parseOptions($options); - - $url = $this->prepareURL($url); + try { + $this->parseOptions($options); - $method = esc(strip_tags($method)); + $url = $this->prepareURL($url); - $this->send($method, $url); + $method = esc(strip_tags($method)); - if ($this->shareOptions === false) { - $this->resetOptions(); + $this->send($method, $url); + } finally { + if ($this->shareOptions === false) { + $this->resetOptions(); + } } return $this->response; @@ -194,12 +205,17 @@ protected function resetOptions() // Reset configs $this->config = $this->defaultConfig; + $this->baseURI = clone $this->defaultBaseURI; + $this->delay = 0.0; + // Set the default options for next request $this->parseOptions($this->defaultOptions); } /** * Convenience method for sending a GET request. + * + * @param array $options */ public function get(string $url, array $options = []): ResponseInterface { @@ -208,6 +224,8 @@ public function get(string $url, array $options = []): ResponseInterface /** * Convenience method for sending a DELETE request. + * + * @param array $options */ public function delete(string $url, array $options = []): ResponseInterface { @@ -216,6 +234,8 @@ public function delete(string $url, array $options = []): ResponseInterface /** * Convenience method for sending a HEAD request. + * + * @param array $options */ public function head(string $url, array $options = []): ResponseInterface { @@ -224,6 +244,8 @@ public function head(string $url, array $options = []): ResponseInterface /** * Convenience method for sending an OPTIONS request. + * + * @param array $options */ public function options(string $url, array $options = []): ResponseInterface { @@ -232,6 +254,8 @@ public function options(string $url, array $options = []): ResponseInterface /** * Convenience method for sending a PATCH request. + * + * @param array $options */ public function patch(string $url, array $options = []): ResponseInterface { @@ -240,6 +264,8 @@ public function patch(string $url, array $options = []): ResponseInterface /** * Convenience method for sending a POST request. + * + * @param array $options */ public function post(string $url, array $options = []): ResponseInterface { @@ -248,6 +274,8 @@ public function post(string $url, array $options = []): ResponseInterface /** * Convenience method for sending a PUT request. + * + * @param array $options */ public function put(string $url, array $options = []): ResponseInterface { @@ -271,7 +299,8 @@ public function setAuth(string $username, #[SensitiveParameter] string $password /** * Set form data to be sent. * - * @param bool $multipart Set TRUE if you are sending CURLFiles + * @param bool $multipart Set TRUE if you are sending CURLFiles + * @param array $params * * @return $this */ @@ -304,6 +333,8 @@ public function setJSON($data) * Sets the correct settings based on the options array * passed in. * + * @param array $options + * * @return void */ protected function parseOptions(array $options) @@ -371,11 +402,13 @@ public function send(string $method, string $url) // Reset our curl options so we're on a fresh slate. $curlOptions = []; - if (! empty($this->config['query']) && is_array($this->config['query'])) { + $query = $this->config['query'] ?? []; + + if (is_array($query) && $query !== []) { // This is likely too naive a solution. // Should look into handling when $url already // has query vars on it. - $url .= '?' . http_build_query($this->config['query']); + $url .= '?' . http_build_query($query); unset($this->config['query']); } @@ -428,10 +461,14 @@ public function send(string $method, string $url) /** * Adds $this->headers to the cURL request. + * + * @param array $curlOptions + * + * @return array */ protected function applyRequestHeaders(array $curlOptions = []): array { - if (empty($this->headers)) { + if ($this->headers === []) { return $curlOptions; } @@ -448,6 +485,10 @@ protected function applyRequestHeaders(array $curlOptions = []): array /** * Apply method + * + * @param array $curlOptions + * + * @return array */ protected function applyMethod(string $method, array $curlOptions): array { @@ -475,10 +516,14 @@ protected function applyMethod(string $method, array $curlOptions): array /** * Apply body + * + * @param array $curlOptions + * + * @return array */ protected function applyBody(array $curlOptions = []): array { - if (! empty($this->body)) { + if (! in_array($this->body, [null, '', '0'], true)) { $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody(); } @@ -489,6 +534,8 @@ protected function applyBody(array $curlOptions = []): array * Parses the header retrieved from the cURL response into * our Response object. * + * @param list $headers + * * @return void */ protected function setResponseHeaders(array $headers = []) @@ -520,27 +567,59 @@ protected function setResponseHeaders(array $headers = []) /** * Set CURL options * - * @return array + * @param array $curlOptions + * @param array $config + * + * @return array * * @throws InvalidArgumentException */ protected function setCURLOptions(array $curlOptions = [], array $config = []) { - // Auth Headers - if (! empty($config['auth'])) { - $curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1]; + $curlOptions = $this->applyAuthOptions($curlOptions, $config); + $curlOptions = $this->applySslOptions($curlOptions, $config); + $curlOptions = $this->applyProxyOptions($curlOptions, $config); + $curlOptions = $this->applyDebugOptions($curlOptions, $config); + $curlOptions = $this->applyRedirectOptions($curlOptions, $config); + $curlOptions = $this->applyConnectionOptions($curlOptions, $config); + $curlOptions = $this->applyBodyOptions($curlOptions, $config); + $curlOptions = $this->applyResponseOptions($curlOptions, $config); + $curlOptions = $this->applyProtocolOptions($curlOptions, $config); + + return $this->applyClientOptions($curlOptions, $config); + } - if (! empty($config['auth'][2]) && strtolower($config['auth'][2]) === 'digest') { - $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_DIGEST; - } else { - $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC; - } + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyAuthOptions(array $curlOptions, array $config): array + { + // Auth Headers + if (isset($config['auth']) && is_array($config['auth']) && count($config['auth']) >= 2) { + $curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1]; + $curlOptions[CURLOPT_HTTPAUTH] = (isset($config['auth'][2]) && $config['auth'][2] !== '' && strtolower($config['auth'][2]) === 'digest') + ? CURLAUTH_DIGEST + : CURLAUTH_BASIC; } + return $curlOptions; + } + + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applySslOptions(array $curlOptions, array $config): array + { // Certificate - if (! empty($config['cert'])) { - $cert = $config['cert']; + $cert = $config['cert'] ?? null; + if ((bool) $cert) { if (is_array($cert)) { $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1]; $cert = $cert[0]; @@ -556,7 +635,8 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) // SSL Verification if (isset($config['verify'])) { if (is_string($config['verify'])) { - $file = realpath($config['verify']) ?: $config['verify']; + $realPath = realpath($config['verify']); + $file = $realPath === false ? $config['verify'] : $realPath; if (! is_file($file)) { throw HTTPException::forInvalidSSLKey($config['verify']); @@ -571,30 +651,51 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) } } + return $curlOptions; + } + + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyProxyOptions(array $curlOptions, array $config): array + { // Proxy if (isset($config['proxy'])) { $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true; $curlOptions[CURLOPT_PROXY] = $config['proxy']; } + return $curlOptions; + } + + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyDebugOptions(array $curlOptions, array $config): array + { // Debug - if ($config['debug']) { + if ((bool) ($config['debug'] ?? false)) { $curlOptions[CURLOPT_VERBOSE] = 1; $curlOptions[CURLOPT_STDERR] = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb'); } - // Decode Content - if (! empty($config['decode_content'])) { - $accept = $this->getHeaderLine('Accept-Encoding'); - - if ($accept !== '') { - $curlOptions[CURLOPT_ENCODING] = $accept; - } else { - $curlOptions[CURLOPT_ENCODING] = ''; - $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding'; - } - } + return $curlOptions; + } + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyRedirectOptions(array $curlOptions, array $config): array + { // Allow Redirects if (array_key_exists('allow_redirects', $config)) { $settings = $this->redirectDefaults; @@ -623,6 +724,17 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) } } + return $curlOptions; + } + + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyConnectionOptions(array $curlOptions, array $config): array + { // DNS Cache Timeout if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) { $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout']; @@ -634,13 +746,33 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) : true; // Timeout - $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000; + $curlOptions[CURLOPT_TIMEOUT_MS] = (float) ($config['timeout'] ?? 0) * 1000; // Connection Timeout - $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) $config['connect_timeout'] * 1000; + $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) ($config['connect_timeout'] ?? 150) * 1000; + + // Resolve IP + if (array_key_exists('force_ip_resolve', $config)) { + $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) { + 'v4' => CURL_IPRESOLVE_V4, + 'v6' => CURL_IPRESOLVE_V6, + default => CURL_IPRESOLVE_WHATEVER, + }; + } + return $curlOptions; + } + + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyBodyOptions(array $curlOptions, array $config): array + { // Post Data - application/x-www-form-urlencoded - if (! empty($config['form_params']) && is_array($config['form_params'])) { + if (isset($config['form_params']) && is_array($config['form_params']) && $config['form_params'] !== []) { $postFields = http_build_query($config['form_params']); $curlOptions[CURLOPT_POSTFIELDS] = $postFields; @@ -651,14 +783,11 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) } // Post Data - multipart/form-data - if (! empty($config['multipart']) && is_array($config['multipart'])) { + if (isset($config['multipart']) && is_array($config['multipart']) && $config['multipart'] !== []) { // setting the POSTFIELDS option automatically sets multipart $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart']; } - // HTTP Errors - $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true; - // JSON if (isset($config['json'])) { // Will be set as the body in `applyBody()` @@ -668,33 +797,76 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) $this->setHeader('Content-Length', (string) strlen($json)); } - // Resolve IP - if (array_key_exists('force_ip_resolve', $config)) { - $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) { - 'v4' => CURL_IPRESOLVE_V4, - 'v6' => CURL_IPRESOLVE_V6, - default => CURL_IPRESOLVE_WHATEVER, - }; - } + return $curlOptions; + } - // version - if (! empty($config['version'])) { - $version = sprintf('%.1F', $config['version']); - if ($version === '1.0') { - $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0; - } elseif ($version === '1.1') { - $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; - } elseif ($version === '2.0') { - $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; - } elseif ($version === '3.0') { - if (! defined('CURL_HTTP_VERSION_3')) { - define('CURL_HTTP_VERSION_3', 30); - } + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyResponseOptions(array $curlOptions, array $config): array + { + // Decode Content + if ((bool) ($config['decode_content'] ?? false)) { + $accept = $this->getHeaderLine('Accept-Encoding'); - $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_3; + if ($accept !== '') { + $curlOptions[CURLOPT_ENCODING] = $accept; + } else { + $curlOptions[CURLOPT_ENCODING] = ''; + $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding'; } } + // HTTP Errors + $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true; + + return $curlOptions; + } + + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyProtocolOptions(array $curlOptions, array $config): array + { + if (! isset($config['version']) || (bool) $config['version'] === false) { + return $curlOptions; + } + + $version = sprintf('%.1F', (float) $config['version']); + + if ($version === '3.0' && ! defined('CURL_HTTP_VERSION_3')) { + define('CURL_HTTP_VERSION_3', 30); + } + + $curlVersion = match ($version) { + '1.0' => CURL_HTTP_VERSION_1_0, + '1.1' => CURL_HTTP_VERSION_1_1, + '2.0' => CURL_HTTP_VERSION_2_0, + '3.0' => CURL_HTTP_VERSION_3, + default => null, + }; + + if ($curlVersion !== null) { + $curlOptions[CURLOPT_HTTP_VERSION] = $curlVersion; + } + + return $curlOptions; + } + + /** + * @param array $curlOptions + * @param array $config + * + * @return array + */ + private function applyClientOptions(array $curlOptions, array $config): array + { // Cookie if (isset($config['cookie'])) { $curlOptions[CURLOPT_COOKIEJAR] = $config['cookie']; diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index c94fed4c8e73..acea6b2b5ad6 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -941,10 +941,6 @@ protected function buildHeaders(ResponseInterface $response) return; } - $response->setHeader('Content-Security-Policy', []); - $response->setHeader('Content-Security-Policy-Report-Only', []); - $response->setHeader('Reporting-Endpoints', []); - if (in_array($this->baseURI, ['', null, []], true)) { $this->baseURI = 'self'; } @@ -967,9 +963,7 @@ protected function buildHeaders(ResponseInterface $response) } } - // Compile our own header strings here since if we just - // append it to the response, it will be joined with - // commas, not semi-colons as we need. + // Compile each generated header value before appending it to the response. if ($this->reportingEndpoints !== []) { $endpoints = []; diff --git a/system/HTTP/Files/FileCollection.php b/system/HTTP/Files/FileCollection.php index 5e3599b89853..c1c0582f6ff5 100644 --- a/system/HTTP/Files/FileCollection.php +++ b/system/HTTP/Files/FileCollection.php @@ -31,7 +31,7 @@ class FileCollection * Populated the first time either files(), file(), or hasFile() * is called. * - * @var array|null + * @var array|UploadedFile>|null */ protected $files; @@ -40,7 +40,7 @@ class FileCollection * Each element in the array will be an instance of UploadedFile. * The key of each element will be the client filename. * - * @return array|null + * @return array|UploadedFile>|null */ public function all() { @@ -167,7 +167,9 @@ protected function populateFiles() * Given a file array, will create UploadedFile instances. Will * loop over an array and create objects for each. * - * @return list|UploadedFile + * @param array $array + * + * @return array|UploadedFile>|UploadedFile */ protected function createFileObject(array $array) { @@ -187,7 +189,7 @@ protected function createFileObject(array $array) return new UploadedFile( $array['tmp_name'] ?? null, - $array['name'] ?? null, + $array['name'], $array['type'] ?? null, ($array['size'] ?? null) === null ? null : (int) $array['size'], $array['error'] ?? null, @@ -203,6 +205,10 @@ protected function createFileObject(array $array) * of this method. * * @see http://php.net/manual/en/reserved.variables.files.php#118294 + * + * @param array $data + * + * @return array */ protected function fixFilesArray(array $data): array { @@ -246,8 +252,8 @@ protected function fixFilesArray(array $data): array /** * Navigate through an array looking for a particular index * - * @param array $index The index sequence we are navigating down - * @param array $value The portion of the array to process + * @param list $index The index sequence we are navigating down + * @param array|UploadedFile> $value The portion of the array to process * * @return list|UploadedFile|null */ diff --git a/system/HTTP/Files/UploadedFile.php b/system/HTTP/Files/UploadedFile.php index 95a393616dff..f469e3e7023a 100644 --- a/system/HTTP/Files/UploadedFile.php +++ b/system/HTTP/Files/UploadedFile.php @@ -55,18 +55,11 @@ class UploadedFile extends File implements UploadedFileInterface */ protected $name; - /** - * The type of file as provided by PHP - * - * @var string - */ - protected $originalMimeType; - /** * The error constant of the upload - * (one of PHP's UPLOADERRXXX constants) + * (one of PHP's UPLOAD_ERR_XXX constants) * - * @var int + * @var int|null */ protected $error; @@ -84,7 +77,7 @@ class UploadedFile extends File implements UploadedFileInterface * @param string $originalName The client-provided filename. * @param string|null $mimeType The type of file as provided by PHP * @param int|null $size The size of the file, in bytes - * @param int|null $error The error constant of the upload (one of PHP's UPLOADERRXXX constants) + * @param int|null $error The error constant of the upload (one of PHP's UPLOAD_ERR_XXX constants) * @param string|null $clientPath The webkit relative path of the uploaded file. */ public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $size = null, ?int $error = null, ?string $clientPath = null) @@ -122,7 +115,9 @@ public function __construct(string $path, string $originalName, ?string $mimeTyp * @see http://php.net/is_uploaded_file * @see http://php.net/move_uploaded_file * - * @param string $targetPath Path to which to move the uploaded file. + * @param string $targetPath Path to which to move the uploaded file. This is NOT sanitized. Never build it from + * untrusted input: a value containing "../" can move the file outside the intended + * directory. Use a path controlled by your application. * @param string|null $name The name to rename the file to. When null, the client-provided name is used and sanitized. * A caller-supplied name is NOT sanitized. * @param bool $overwrite State for indicating whether to overwrite the previously generated file with the same @@ -164,7 +159,7 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite = throw HTTPException::forMoveFailed(basename($this->path), $targetPath, $message); } - @chmod($targetPath, 0777 & ~umask()); + @chmod($destination, 0666 & ~umask()); // Success, so store our new information $this->path = $targetPath; @@ -347,8 +342,10 @@ public function isValid(): bool * By default, upload files are saved in writable/uploads directory. The YYYYMMDD folder * and random file name will be created. * - * @param string|null $folderName the folder name to writable/uploads directory. - * @param string|null $fileName the name to rename the file to. + * @param string|null $folderName the folder name to writable/uploads directory. This is NOT sanitized. Never build + * it from untrusted input: a value containing "../" can move the file outside the + * uploads directory. + * @param string|null $fileName the name to rename the file to. A caller-supplied name is NOT sanitized. * * @return string file full path */ diff --git a/system/HTTP/Files/UploadedFileInterface.php b/system/HTTP/Files/UploadedFileInterface.php index 6e849d04a76b..edc88e500573 100644 --- a/system/HTTP/Files/UploadedFileInterface.php +++ b/system/HTTP/Files/UploadedFileInterface.php @@ -32,7 +32,7 @@ interface UploadedFileInterface * @param string $originalName The client-provided filename. * @param string|null $mimeType The type of file as provided by PHP * @param int|null $size The size of the file, in bytes - * @param int|null $error The error constant of the upload (one of PHP's UPLOADERRXXX constants) + * @param int|null $error The error constant of the upload (one of PHP's UPLOAD_ERR_XXX constants) * @param string|null $clientPath The webkit relative path of the uploaded file. */ public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $size = null, ?int $error = null, ?string $clientPath = null); diff --git a/system/HTTP/Header.php b/system/HTTP/Header.php index a21f9961536a..54f162eb7caa 100644 --- a/system/HTTP/Header.php +++ b/system/HTTP/Header.php @@ -44,14 +44,14 @@ class Header implements Stringable * 'baz' => 'buzz', * ] * - * @var array|string>|string + * @var array|string>|string */ protected $value; /** * Header constructor. name is mandatory, if a value is provided, it will be set. * - * @param array|string>|string|null $value + * @param array|string>|int|string|null $value */ public function __construct(string $name, $value = null) { @@ -71,7 +71,7 @@ public function getName(): string * Gets the raw value of the header. This may return either a string * or an array, depending on whether the header has multiple values or not. * - * @return array|string>|string + * @return array|string>|string */ public function getValue() { @@ -96,7 +96,7 @@ public function setName(string $name) /** * Sets the value of the header, overwriting any previous value(s). * - * @param array|string>|string|null $value + * @param array|int|string>|int|string|null $value * * @return $this * @@ -235,7 +235,7 @@ private function validateName(string $name): void * * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 * - * @param array|string>|int|string $value + * @param array|int|string>|int|string $value * * @throws InvalidArgumentException */ diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 3a43d6dec050..cc66e35f4ddc 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -105,14 +105,14 @@ class IncomingRequest extends Request /** * Stores the valid locale codes. * - * @var array + * @var list */ protected $validLocales = []; /** * Holds the old data from a redirect. * - * @var array + * @var array */ protected $oldInput = []; @@ -203,12 +203,12 @@ public function detectLocale($config) /** * Provides a convenient way to work with the Negotiate class * for content negotiation. + * + * @param list $supported */ public function negotiate(string $type, array $supported, bool $strictMatch = false): string { - if ($this->negotiator === null) { - $this->negotiator = Services::negotiator($this, true); - } + $this->negotiator ??= Services::negotiator($this, true); return match (strtolower($type)) { 'media' => $this->negotiator->media($supported, $strictMatch), @@ -280,7 +280,14 @@ public function isSecure(): bool return true; } - return $this->hasHeader('Front-End-Https') && ! empty($this->header('Front-End-Https')->getValue()) && strtolower($this->header('Front-End-Https')->getValue()) !== 'off'; + if (! $this->hasHeader('Front-End-Https')) { + return false; + } + + $frontEndHttps = $this->header('Front-End-Https')->getValue(); + + return is_string($frontEndHttps) && ! in_array($frontEndHttps, ['', '0'], true) + && strtolower($frontEndHttps) !== 'off'; } /** @@ -332,6 +339,8 @@ public function setLocale(string $locale) /** * Set the valid locales. * + * @param list $locales + * * @return $this */ public function setValidLocales(array $locales) @@ -363,11 +372,11 @@ public function getDefaultLocale(): string * to grab data from the request object and can be used in lieu of the * other get* methods in most cases. * - * @param array|string|null $index - * @param int|null $filter Filter constant - * @param array|int|null $flags + * @param list|string|null $index + * @param int|null $filter Filter constant + * @param array|int|null $flags * - * @return array|bool|float|int|stdClass|string|null + * @return array|bool|float|int|stdClass|string|null */ public function getVar($index = null, $filter = null, $flags = null) { @@ -394,7 +403,7 @@ public function getVar($index = null, $filter = null, $flags = null) * * @see http://php.net/manual/en/function.json-decode.php * - * @return array|bool|float|int|stdClass|null + * @return ($assoc is true ? array|bool|float|int|string|null : array|bool|float|int|stdClass|string|null) * * @throws HTTPException When the body is invalid as JSON. */ @@ -416,12 +425,12 @@ public function getJSON(bool $assoc = false, int $depth = 512, int $options = 0) /** * Get a specific variable from a JSON input stream * - * @param array|string|null $index The variable that you want which can use dot syntax for getting specific values. - * @param bool $assoc If true, return the result as an associative array. - * @param int|null $filter Filter Constant - * @param array|int|null $flags Option + * @param list|string|null $index The variable that you want which can use dot syntax for getting specific values. + * @param bool $assoc If true, return the result as an associative array. + * @param int|null $filter Filter Constant + * @param array|int|null $flags Option * - * @return array|bool|float|int|stdClass|string|null + * @return ($assoc is true ? array|bool|float|int|string|null : array|bool|float|int|stdClass|string|null) */ public function getJsonVar($index = null, bool $assoc = false, ?int $filter = null, $flags = null) { @@ -496,7 +505,7 @@ public function getJsonVar($index = null, bool $assoc = false, ?int $filter = nu * A convenience method that grabs the raw input stream(send method in PUT, PATCH, DELETE) and decodes * the String into an array. * - * @return array + * @return array */ public function getRawInput() { @@ -508,9 +517,9 @@ public function getRawInput() /** * Gets a specific variable from raw input stream (send method in PUT, PATCH, DELETE). * - * @param array|string|null $index The variable that you want which can use dot syntax for getting specific values. - * @param int|null $filter Filter Constant - * @param array|int|null $flags Option + * @param list|string|null $index The variable that you want which can use dot syntax for getting specific values. + * @param int|null $filter Filter Constant + * @param array|int|null $flags Option * * @return mixed */ @@ -562,9 +571,9 @@ public function getRawInputVar($index = null, ?int $filter = null, $flags = null /** * Fetch an item from GET data. * - * @param array|string|null $index Index for item to fetch from $_GET. - * @param int|null $filter A filter name to apply. - * @param array|int|null $flags + * @param list|string|null $index Index for item to fetch from $_GET. + * @param int|null $filter A filter name to apply. + * @param array|int|null $flags * * @return mixed */ @@ -576,9 +585,9 @@ public function getGet($index = null, $filter = null, $flags = null) /** * Fetch an item from POST. * - * @param array|string|null $index Index for item to fetch from $_POST. - * @param int|null $filter A filter name to apply - * @param array|int|null $flags + * @param list|string|null $index Index for item to fetch from $_POST. + * @param int|null $filter A filter name to apply + * @param array|int|null $flags * * @return mixed */ @@ -590,9 +599,9 @@ public function getPost($index = null, $filter = null, $flags = null) /** * Fetch an item from POST data with fallback to GET. * - * @param array|string|null $index Index for item to fetch from $_POST or $_GET - * @param int|null $filter A filter name to apply - * @param array|int|null $flags + * @param list|string|null $index Index for item to fetch from $_POST or $_GET + * @param int|null $filter A filter name to apply + * @param array|int|null $flags * * @return mixed */ @@ -623,9 +632,9 @@ public function getPostGet($index = null, $filter = null, $flags = null) /** * Fetch an item from GET data with fallback to POST. * - * @param array|string|null $index Index for item to be fetched from $_GET or $_POST - * @param int|null $filter A filter name to apply - * @param array|int|null $flags + * @param list|string|null $index Index for item to be fetched from $_GET or $_POST + * @param int|null $filter A filter name to apply + * @param array|int|null $flags * * @return mixed */ @@ -656,9 +665,9 @@ public function getGetPost($index = null, $filter = null, $flags = null) /** * Fetch an item from the COOKIE array. * - * @param array|string|null $index Index for item to be fetched from $_COOKIE - * @param int|null $filter A filter name to be applied - * @param array|int|null $flags + * @param list|string|null $index Index for item to be fetched from $_COOKIE + * @param int|null $filter A filter name to be applied + * @param array|int|null $flags * * @return mixed */ @@ -682,7 +691,7 @@ public function getUserAgent() * with redirect_with_input(). It first checks for the data in the old * POST data, then the old GET data and finally check for dot arrays * - * @return array|string|null + * @return array|string|null */ public function getOldInput(string $key) { @@ -734,12 +743,12 @@ public function getOldInput(string $key) /** * Returns an array of all files that have been uploaded with this * request. Each file is represented by an UploadedFile instance. + * + * @return array|UploadedFile> */ public function getFiles(): array { - if ($this->files === null) { - $this->files = new FileCollection(); - } + $this->files ??= new FileCollection(); return $this->files->all(); // return all files } @@ -748,13 +757,11 @@ public function getFiles(): array * Verify if a file exist, by the name of the input field used to upload it, in the collection * of uploaded files and if is have been uploaded with multiple option. * - * @return array|null + * @return list|null */ public function getFileMultiple(string $fileID) { - if ($this->files === null) { - $this->files = new FileCollection(); - } + $this->files ??= new FileCollection(); return $this->files->getFileMultiple($fileID); } @@ -767,9 +774,7 @@ public function getFileMultiple(string $fileID) */ public function getFile(string $fileID) { - if ($this->files === null) { - $this->files = new FileCollection(); - } + $this->files ??= new FileCollection(); return $this->files->getFile($fileID); } diff --git a/system/HTTP/Message.php b/system/HTTP/Message.php index 5a490e09f780..7c5114f71f63 100644 --- a/system/HTTP/Message.php +++ b/system/HTTP/Message.php @@ -27,14 +27,14 @@ class Message implements MessageInterface /** * Protocol version * - * @var string + * @var string|null */ protected $protocolVersion; /** * List of valid protocol versions * - * @var array + * @var list */ protected $validProtocolVersions = [ '1.0', @@ -80,13 +80,13 @@ public function getHeaders(): array * 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 + * + * @return array|Header|null */ public function getHeader(string $name) { diff --git a/system/HTTP/MessageInterface.php b/system/HTTP/MessageInterface.php index b8850a0ffb7c..11fef4880899 100644 --- a/system/HTTP/MessageInterface.php +++ b/system/HTTP/MessageInterface.php @@ -103,9 +103,9 @@ public function header($name); public function getHeaderLine(string $name): string; /** - * Sets a header and it's value. + * Sets a header and its value. * - * @param array|string|null $value + * @param array|string|null $value * * @return $this */ diff --git a/system/HTTP/MessageTrait.php b/system/HTTP/MessageTrait.php index aed6987f34d7..a55f9dc78c10 100644 --- a/system/HTTP/MessageTrait.php +++ b/system/HTTP/MessageTrait.php @@ -41,20 +41,17 @@ trait MessageTrait * and their normal-case key as it is in $headers. * Used for case-insensitive header access. * - * @var array + * @var array */ protected $headerMap = []; // -------------------------------------------------------------------- // Body // -------------------------------------------------------------------- - /** * Sets the body of the current message. * - * @param string $data - * - * @return $this + * @param array|string|null $data */ public function setBody($data): self { @@ -67,8 +64,6 @@ public function setBody($data): self * Appends data to the body of the current message. * * @param string $data - * - * @return $this */ public function appendBody($data): self { @@ -87,7 +82,7 @@ public function appendBody($data): self public function populateHeaders(): void { $contentType = service('superglobals')->server('CONTENT_TYPE', (string) getenv('CONTENT_TYPE')); - if (! empty($contentType)) { + if ($contentType !== '') { $this->setHeader('Content-Type', $contentType); } unset($contentType); @@ -118,7 +113,7 @@ public function headers(): array // If no headers are defined, but the user is // requesting it, then it's likely they want // it to be populated so do that... - if (empty($this->headers)) { + if ($this->headers === []) { $this->populateHeaders(); } @@ -143,9 +138,7 @@ public function header($name) /** * Sets a header and it's value. * - * @param array|string|null $value - * - * @return $this + * @param array|string|null $value */ public function setHeader(string $name, $value): self { @@ -191,8 +184,6 @@ private function checkMultipleHeaders(string $name): void /** * Removes a header from the list of headers we track. - * - * @return $this */ public function removeHeader(string $name): self { @@ -205,8 +196,6 @@ public function removeHeader(string $name): self /** * Adds an additional header value to any headers that accept * multiple values (i.e. are an array or implement ArrayAccess) - * - * @return $this */ public function appendHeader(string $name, ?string $value): self { @@ -225,8 +214,6 @@ public function appendHeader(string $name, ?string $value): self * Adds a header (not a header value) with the same name. * Use this only when you set multiple headers with the same name, * typically, for `Set-Cookie`. - * - * @return $this */ public function addHeader(string $name, string $value): static { @@ -251,8 +238,6 @@ public function addHeader(string $name, string $value): static /** * Adds an additional header value to any headers that accept * multiple values (i.e. are an array or implement ArrayAccess) - * - * @return $this */ public function prependHeader(string $name, string $value): self { @@ -277,8 +262,6 @@ protected function getHeaderName(string $name): string /** * Sets the HTTP protocol version. * - * @return $this - * * @throws HTTPException For invalid protocols */ public function setProtocolVersion(string $version): self diff --git a/system/HTTP/Negotiate.php b/system/HTTP/Negotiate.php index f35a761c2fc4..d482d086e4c9 100644 --- a/system/HTTP/Negotiate.php +++ b/system/HTTP/Negotiate.php @@ -69,8 +69,9 @@ public function setRequest(RequestInterface $request) * If no match is found, the first, highest-ranking client requested * type is returned. * - * @param bool $strictMatch If TRUE, will return an empty string when no match found. - * If FALSE, will return the first supported element. + * @param bool $strictMatch If TRUE, will return an empty string when no match found. + * If FALSE, will return the first supported element. + * @param list $supported */ public function media(array $supported, bool $strictMatch = false): string { @@ -84,6 +85,8 @@ public function media(array $supported, bool $strictMatch = false): string * * If no match is found, the first, highest-ranking client requested * type is returned. + * + * @param list $supported */ public function charset(array $supported): string { @@ -110,6 +113,8 @@ public function charset(array $supported): string * * If no match is found, the first, highest-ranking client requested * type is returned. + * + * @param list $supported */ public function encoding(array $supported = []): string { @@ -125,6 +130,8 @@ public function encoding(array $supported = []): string * * If strict locale negotiation is disabled and no match is found, the first, highest-ranking client requested * type is returned. + * + * @param list $supported */ public function language(array $supported): string { @@ -145,12 +152,12 @@ public function language(array $supported): string * * Portions of this code base on Aura.Accept library. * - * @param array $supported App-supported values - * @param string $header header string - * @param bool $enforceTypes If TRUE, will compare media types and sub-types. - * @param bool $strictMatch If TRUE, will return empty string on no match. - * If FALSE, will return the first supported element. - * @param bool $matchLocales If TRUE, will match locale sub-types to a broad type (fr-FR = fr) + * @param list $supported App-supported values + * @param string $header header string + * @param bool $enforceTypes If TRUE, will compare media types and sub-types. + * @param bool $strictMatch If TRUE, will return empty string on no match. + * If FALSE, will return the first supported element. + * @param bool $matchLocales If TRUE, will match locale sub-types to a broad type (fr-FR = fr) * * @return string Best match */ @@ -261,6 +268,8 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header): * Parses an Accept* header into it's multiple values. * * This is based on code from Aura.Accept library. + * + * @return list}> */ public function parseHeader(string $header): array { @@ -339,7 +348,8 @@ public function parseHeader(string $header): array /** * Match-maker * - * @param bool $matchLocales + * @param bool $matchLocales + * @param array{value: string, q: float, params: array} $acceptable */ protected function match(array $acceptable, string $supported, bool $enforceTypes = false, $matchLocales = false): bool { @@ -370,6 +380,9 @@ protected function match(array $acceptable, string $supported, bool $enforceType /** * Checks two Accept values with matching 'values' to see if their * 'params' are the same. + * + * @param array{value: string, q: float, params: array} $acceptable + * @param array{value: string, q: float, params: array} $supported */ protected function matchParameters(array $acceptable, array $supported): bool { @@ -391,6 +404,9 @@ protected function matchParameters(array $acceptable, array $supported): bool /** * Compares the types/subtypes of an acceptable Media type and * the supported string. + * + * @param array{value: string, q: float, params: array} $acceptable + * @param array{value: string, q: float, params: array} $supported */ public function matchTypes(array $acceptable, array $supported): bool { @@ -416,6 +432,9 @@ public function matchTypes(array $acceptable, array $supported): bool /** * Will match locales against their broader pairs, so that fr-FR would * match a supported localed of fr + * + * @param array{value: string, q: float, params: array} $acceptable + * @param array{value: string, q: float, params: array} $supported */ public function matchLocales(array $acceptable, array $supported): bool { diff --git a/system/HTTP/OutgoingRequest.php b/system/HTTP/OutgoingRequest.php index eba5babdf6bb..1534bdcb8308 100644 --- a/system/HTTP/OutgoingRequest.php +++ b/system/HTTP/OutgoingRequest.php @@ -35,8 +35,9 @@ class OutgoingRequest extends Message implements OutgoingRequestInterface protected $uri; /** - * @param string $method HTTP method - * @param string|null $body + * @param string $method HTTP method + * @param array|string> $headers + * @param string|null $body */ public function __construct( string $method, diff --git a/system/HTTP/RedirectResponse.php b/system/HTTP/RedirectResponse.php index 50c6b649373e..ed464244a63d 100644 --- a/system/HTTP/RedirectResponse.php +++ b/system/HTTP/RedirectResponse.php @@ -47,7 +47,8 @@ public function to(string $uri, ?int $code = null, string $method = 'auto') * Sets the URI to redirect to but as a reverse-routed or named route * instead of a raw URI. * - * @param string $route Route name or Controller::method + * @param string $route Route name or Controller::method + * @param list $params * * @return $this * @@ -108,8 +109,6 @@ public function withInput() * If the validation has any errors, transmit those back * so they can be displayed when the validation is handled * within a method different than displaying the form. - * - * @return $this */ private function withErrors(): self { @@ -125,7 +124,8 @@ private function withErrors(): self /** * Adds a key and message to the session as Flashdata. * - * @param array|string $message + * @param array|string $message + * @param array|string $message * * @return $this */ diff --git a/system/HTTP/Request.php b/system/HTTP/Request.php index 125646b6c7c7..63b5f0a8dbfd 100644 --- a/system/HTTP/Request.php +++ b/system/HTTP/Request.php @@ -33,11 +33,11 @@ public function __construct($config = null) { $this->config = $config ?? config(App::class); - if (empty($this->method)) { + if ($this->method === null || $this->method === '') { $this->method = $this->getServer('REQUEST_METHOD') ?? Method::GET; } - if (empty($this->uri)) { + if (! $this->uri instanceof URI) { $this->uri = new URI(); } } diff --git a/system/HTTP/RequestInterface.php b/system/HTTP/RequestInterface.php index 26af51df9dab..533d3b9876a9 100644 --- a/system/HTTP/RequestInterface.php +++ b/system/HTTP/RequestInterface.php @@ -32,8 +32,8 @@ public function getIPAddress(): string; * Fetch an item from the $_SERVER array. * Supplied by RequestTrait. * - * @param array|string|null $index Index for item to be fetched from $_SERVER - * @param int|null $filter A filter name to be applied + * @param list|string|null $index Index for item to be fetched from $_SERVER + * @param int|null $filter A filter name to be applied * * @return mixed */ diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index 9011f4148eba..973757c8559e 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -46,7 +46,7 @@ trait RequestTrait /** * Stores values we've retrieved from PHP globals. * - * @var array{get?: array, post?: array, request?: array, cookie?: array, server?: array} + * @var array{get?: array, post?: array, request?: array, cookie?: array, server?: array} * * @deprecated 4.7.0 Use the Superglobals service instead */ @@ -72,7 +72,7 @@ public function getIPAddress(): string $proxyIPs = $this->config->proxyIPs; - if (! empty($proxyIPs) && (! is_array($proxyIPs) || is_int(array_key_first($proxyIPs)))) { + if ($proxyIPs !== [] && (! is_array($proxyIPs) || is_int(array_key_first($proxyIPs)))) { throw new ConfigException( 'You must set an array with Proxy IP address key and HTTP header name value in Config\App::$proxyIPs.', ); @@ -221,9 +221,9 @@ private function checkIPAgainstProxy(string $ip, string $proxyIP): bool /** * Fetch an item from the $_SERVER array. * - * @param array|string|null $index Index for item to be fetched from $_SERVER - * @param int|null $filter A filter name to be applied - * @param array|int|null $flags + * @param list|string|null $index Index for item to be fetched from $_SERVER + * @param int|null $filter A filter name to be applied + * @param array|int|null $flags * * @return mixed */ @@ -235,9 +235,9 @@ public function getServer($index = null, $filter = null, $flags = null) /** * 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 + * @param list|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 * @@ -279,9 +279,9 @@ public function setGlobal(string $name, $value) * http://php.net/manual/en/filter.filters.sanitize.php * * @param 'cookie'|'get'|'post'|'request'|'server' $name Superglobal name (lowercase) - * @param array|int|string|null $index + * @param int|list|string|null $index * @param int|null $filter Filter constant - * @param array|int|null $flags Options + * @param array|int|null $flags Options * * @return mixed */ @@ -338,9 +338,7 @@ public function fetchGlobal(string $name, $index = null, ?int $filter = null, $f } } - if (! isset($value)) { - $value = $this->globals[$name][$index] ?? null; - } + $value ??= $this->globals[$name][$index] ?? null; if (is_array($value) && ( @@ -379,9 +377,7 @@ public function fetchGlobal(string $name, $index = null, ?int $filter = null, $f */ protected function populateGlobals(string $name) { - if (! isset($this->globals[$name])) { - $this->globals[$name] = []; - } + $this->globals[$name] ??= []; // Get data from Superglobals service instead of direct access $this->globals[$name] = service('superglobals')->getGlobalArray($name); diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index 3c59f2e9800d..2ac2ecb7e9d2 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -39,7 +39,7 @@ class Response extends Message implements ResponseInterface /** * HTTP status codes * - * @var array + * @var array */ protected static $statusCodes = [ // 1xx: Informational @@ -197,7 +197,7 @@ public function pretend(bool $pretend = true) */ public function getStatusCode(): int { - if (empty($this->statusCode)) { + if ($this->statusCode === 0) { throw HTTPException::forMissingResponseStatus(); } @@ -221,7 +221,7 @@ public function getStatusCode(): int public function getReasonPhrase() { if ($this->reason === '') { - return empty($this->statusCode) ? '' : static::$statusCodes[$this->statusCode]; + return $this->statusCode === 0 ? '' : static::$statusCodes[$this->statusCode]; } return $this->reason; diff --git a/system/HTTP/ResponseInterface.php b/system/HTTP/ResponseInterface.php index 66053c412939..a1fbcb4acc06 100644 --- a/system/HTTP/ResponseInterface.php +++ b/system/HTTP/ResponseInterface.php @@ -200,7 +200,7 @@ public function setContentType(string $mime, string $charset = 'UTF-8'); /** * Converts the $body into JSON and sets the Content Type header. * - * @param array|object|string $body + * @param mixed $body * * @return $this */ @@ -218,7 +218,7 @@ public function getJSON(); /** * Converts $body into XML, and sets the correct Content-Type. * - * @param array|string $body + * @param mixed $body * * @return $this */ @@ -271,6 +271,8 @@ public function noCache(); * - proxy-revalidate * - no-transform * + * @param array $options + * * @return $this */ public function setCache(array $options = []); @@ -310,15 +312,15 @@ public function sendBody(); * Accepts an arbitrary number of binds (up to 7) or an associative * array in the first parameter containing all the values. * - * @param array|Cookie|string $name Cookie name / array containing binds / Cookie object - * @param string $value Cookie value - * @param int $expire Cookie expiration time in seconds - * @param string $domain Cookie domain (e.g.: '.yourdomain.com') - * @param string $path Cookie path (default: '/') - * @param string $prefix Cookie name prefix - * @param bool $secure Whether to only transfer cookies via SSL - * @param bool $httponly Whether only make the cookie accessible via HTTP (no javascript) - * @param string|null $samesite + * @param array|Cookie|string $name Cookie name / array containing binds / Cookie object + * @param string $value Cookie value + * @param int|string $expire Cookie expiration time in seconds + * @param string $domain Cookie domain (e.g.: '.yourdomain.com') + * @param string $path Cookie path (default: '/') + * @param string $prefix Cookie name prefix + * @param bool $secure Whether to only transfer cookies via SSL + * @param bool $httponly Whether only make the cookie accessible via HTTP (no javascript) + * @param string|null $samesite * * @return $this */ diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index 265a62fb0a1f..5b7e02c2a954 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -169,7 +169,7 @@ public function setContentType(string $mime, string $charset = 'UTF-8') /** * Converts the $body into JSON and sets the Content Type header. * - * @param array|object|string $body + * @param mixed $body * * @return $this */ @@ -195,13 +195,13 @@ public function getJSON() $body = service('format')->getFormatter('application/json')->format($body); } - return $body ?: null; + return ($body === null || $body === '') ? null : $body; } /** * Converts $body into XML, and sets the correct Content-Type. * - * @param array|string $body + * @param mixed $body * * @return $this */ @@ -224,7 +224,7 @@ public function getXML() $body = $this->body; if ($this->bodyFormat !== 'xml') { - $body = service('format')->getFormatter('application/xml')->format($body); + return service('format')->getFormatter('application/xml')->format($body); } return $body; @@ -234,8 +234,8 @@ public function getXML() * Handles conversion of the data into the appropriate format, * and sets the correct Content-Type header for our response. * - * @param array|object|string $body - * @param string $format Valid: json, xml + * @param mixed $body + * @param string $format Valid: json, xml * * @return false|string * @@ -249,7 +249,7 @@ protected function formatBody($body, string $format) // Nothing much to do for a string... if (! is_string($body) || $format === 'json-unencoded') { - $body = service('format')->getFormatter($mime)->format($body); + return service('format')->getFormatter($mime)->format($body); } return $body; @@ -475,9 +475,7 @@ public function redirect(string $uri, string $method = 'auto', ?int $code = null } } - if ($code === null) { - $code = 302; - } + $code ??= 302; match ($method) { 'refresh' => $this->setHeader('Refresh', '0;url=' . $uri), @@ -495,15 +493,16 @@ public function redirect(string $uri, string $method = 'auto', ?int $code = null * Accepts an arbitrary number of binds (up to 7) or an associative * array in the first parameter containing all the values. * - * @param array|Cookie|string $name Cookie name / array containing binds / Cookie object - * @param string $value Cookie value - * @param int $expire Cookie expiration time in seconds - * @param string $domain Cookie domain (e.g.: '.yourdomain.com') - * @param string $path Cookie path (default: '/') - * @param string $prefix Cookie name prefix ('': the default prefix) - * @param bool|null $secure Whether to only transfer cookies via SSL - * @param bool|null $httponly Whether only make the cookie accessible via HTTP (no javascript) - * @param string|null $samesite + * @param array|Cookie|string $name Cookie name / array containing binds / Cookie object + * @param string $value Cookie value + * @param int|string $expire Cookie expiration time in seconds + * @param string $domain Cookie domain (e.g.: '.yourdomain.com') + * @param string $path Cookie path (default: '/') + * @param string $prefix Cookie name prefix ('': the default prefix) + * @param bool|null $secure Whether to only transfer cookies via SSL + * @param bool|null $httponly Whether only make the cookie accessible via HTTP (no javascript) + * @param string|null $samesite + * @param array|Cookie|string $name * * @return $this */ @@ -541,10 +540,12 @@ public function setCookie( if (is_numeric($expire)) { $expire = $expire > 0 ? Time::now()->getTimestamp() + $expire : 0; + } else { + $expire = 0; } $cookie = new Cookie($name, $value, [ - 'expires' => $expire ?: 0, + 'expires' => $expire, 'domain' => $domain, 'path' => $path, 'prefix' => $prefix, @@ -698,6 +699,8 @@ private function dispatchCookies(): void * Extracted call to `setrawcookie()` in order to run unit tests on it. * * @codeCoverageIgnore + * + * @param array $options */ private function doSetRawCookie(string $name, string $value, array $options): void { @@ -708,6 +711,8 @@ private function doSetRawCookie(string $name, string $value, array $options): vo * Extracted call to `setcookie()` in order to run unit tests on it. * * @codeCoverageIgnore + * + * @param array $options */ private function doSetCookie(string $name, string $value, array $options): void { diff --git a/system/HTTP/SiteURI.php b/system/HTTP/SiteURI.php index d6653b10f537..403269008e56 100644 --- a/system/HTTP/SiteURI.php +++ b/system/HTTP/SiteURI.php @@ -53,6 +53,8 @@ class SiteURI extends URI * 1 => 'public', * 2 => 'index.php', * ]; + * + * @var list */ private array $baseSegments; @@ -85,10 +87,10 @@ class SiteURI extends URI private string $routePath; /** - * @param string $relativePath URI path relative to baseURL. May include - * queries or fragments. - * @param string|null $host Optional current hostname. - * @param 'http'|'https'|null $scheme Optional scheme. 'http' or 'https'. + * @param string $relativePath URI path relative to baseURL. May include + * queries or fragments. + * @param string|null $host Optional current hostname. + * @param ''|'http'|'https'|null $scheme Optional scheme. 'http' or 'https'. */ public function __construct( App $configApp, @@ -123,6 +125,9 @@ public function __construct( $this->setRoutePath($routePath); } + /** + * @return array{string, string, string} + */ private function parseRelativePath(string $relativePath): array { $parts = parse_url('http://dummy/' . $relativePath); @@ -183,7 +188,7 @@ private function getIndexPageRoutePath(string $routePath): string $indexPageRoutePath = $indexPage . $routePath; if ($indexPageRoutePath === '/') { - $indexPageRoutePath = ''; + return ''; } return $indexPageRoutePath; @@ -299,6 +304,8 @@ private function setRoutePath(string $routePath): void /** * Converts path to segments + * + * @return list */ private function convertToSegments(string $path): array { @@ -383,10 +390,10 @@ protected function applyParts(array $parts): void /** * For base_url() helper. * - * @param array|string $relativePath URI string or array of URI segments. - * @param string|null $scheme URI scheme. E.g., http, ftp. If empty - * string '' is set, a protocol-relative - * link is returned. + * @param list|string $relativePath URI string or array of URI segments. + * @param string|null $scheme URI scheme. E.g., http, ftp. If empty + * string '' is set, a protocol-relative + * link is returned. */ public function baseUrl($relativePath = '', ?string $scheme = null): string { @@ -408,12 +415,12 @@ public function baseUrl($relativePath = '', ?string $scheme = null): string } /** - * @param array|string $relativePath URI string or array of URI segments + * @param list|string $relativePath URI string or array of URI segments */ private function stringifyRelativePath($relativePath): string { if (is_array($relativePath)) { - $relativePath = implode('/', $relativePath); + return implode('/', $relativePath); } return $relativePath; @@ -422,11 +429,11 @@ private function stringifyRelativePath($relativePath): string /** * For site_url() helper. * - * @param array|string $relativePath URI string or array of URI segments. - * @param string|null $scheme URI scheme. E.g., http, ftp. If empty - * string '' is set, a protocol-relative - * link is returned. - * @param App|null $config Alternate configuration to use. + * @param list|string $relativePath URI string or array of URI segments. + * @param string|null $scheme URI scheme. E.g., http, ftp. If empty + * string '' is set, a protocol-relative + * link is returned. + * @param App|null $config Alternate configuration to use. */ public function siteUrl($relativePath = '', ?string $scheme = null, ?App $config = null): string { diff --git a/system/HTTP/SiteURIFactory.php b/system/HTTP/SiteURIFactory.php index 73cc9fe84ee4..11dccce6c540 100644 --- a/system/HTTP/SiteURIFactory.php +++ b/system/HTTP/SiteURIFactory.php @@ -227,7 +227,7 @@ private function createURIFromRoutePath(string $routePath): SiteURI */ private function getHost(): ?string { - $httpHostPort = $this->superglobals->server('HTTP_HOST') ?? null; + $httpHostPort = $this->superglobals->server('HTTP_HOST'); if ($httpHostPort !== null) { [$httpHost] = explode(':', $httpHostPort, 2); diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index bc848ba5c73e..4b8b324dc80e 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -541,7 +541,7 @@ public function getQuery(array $options = []): string */ public function getFragment(): string { - return $this->fragment ?? ''; + return $this->fragment; } /** @@ -697,9 +697,7 @@ public function setAuthority(string $str) { $parts = parse_url($str); - if (! isset($parts['path'])) { - $parts['path'] = $this->getPath(); - } + $parts['path'] ??= $this->getPath(); if (! isset($parts['host']) && $parts['path'] !== '') { $parts['host'] = $parts['path']; @@ -917,9 +915,10 @@ public function setQuery(string $query) * A convenience method to pass an array of items in as the Query * portion of the URI. * - * @return URI - * * @TODO: PSR-7: Should be `withQueryParams(array $query)` + * @param array $query + * + * @return URI */ public function setQueryArray(array $query) { diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index 1e7d6904796c..d370e1f1112f 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -33,7 +33,8 @@ 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 $array * * @return mixed */ @@ -68,6 +69,9 @@ private static function convertToArray(string $index): array * * @used-by dotSearch() * + * @param list $indexes + * @param array $array + * * @return mixed */ private static function arraySearchDot(array $indexes, array $array) @@ -125,6 +129,8 @@ 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 $array */ public static function dotKeyExists(string $index, array $array): bool { @@ -179,11 +185,11 @@ public static function dotKeyExists(string $index, array $array): bool * * @used-by array_group_by() * - * @param array $array Data array (i.e. from query result) - * @param array $indexes Indexes to group by. Dot syntax used. Returns $array if empty - * @param bool $includeEmpty If true, null and '' are also added as valid keys to group + * @param array $array Data array (i.e. from query result) + * @param list $indexes Indexes to group by. Dot syntax used. Returns $array if empty + * @param bool $includeEmpty If true, null and '' are also added as valid keys to group * - * @return array Result array where rows are grouped together by indexes values. + * @return array Result array where rows are grouped together by indexes values. */ public static function groupBy(array $array, array $indexes, bool $includeEmpty = false): array { @@ -205,6 +211,12 @@ public static function groupBy(array $array, array $indexes, bool $includeEmpty * `dot_array_search()`. * * @used-by groupBy() + * + * @param array $result + * @param array $row + * @param list $indexes + * + * @return array */ private static function arrayAttachIndexedValue( array $result, @@ -244,6 +256,11 @@ private static function arrayAttachIndexedValue( /** * Compare recursively two associative arrays and return difference as new array. * Returns keys that exist in `$original` but not in `$compareWith`. + * + * @param array $original + * @param array $compareWith + * + * @return array */ public static function recursiveDiff(array $original, array $compareWith): array { @@ -284,6 +301,8 @@ public static function recursiveDiff(array $original, array $compareWith): array /** * Recursively count all keys. + * + * @param array $array */ public static function recursiveCount(array $array, int $counter = 0): int { @@ -305,7 +324,7 @@ public static function recursiveCount(array $array, int $counter = 0): int * @param list|string> $array * @param int|string|null $sortByIndex */ - public static function sortValuesByNatural(array &$array, $sortByIndex = null): bool + public static function sortValuesByNatural(array &$array, $sortByIndex = null): true { return usort($array, static function ($currentValue, $nextValue) use ($sortByIndex): int { if ($sortByIndex !== null) { diff --git a/system/Helpers/array_helper.php b/system/Helpers/array_helper.php index 4d5d12488d78..c5ccde761544 100644 --- a/system/Helpers/array_helper.php +++ b/system/Helpers/array_helper.php @@ -20,9 +20,9 @@ * Searches an array through dot syntax. Supports * wildcard searches, like foo.*.bar * - * @return mixed + * @param array $array */ - function dot_array_search(string $index, array $array) + function dot_array_search(string $index, array $array): mixed { return ArrayHelper::dotSearch($index, $array); } @@ -32,11 +32,10 @@ function dot_array_search(string $index, array $array) /** * Returns the value of an element at a key in an array of uncertain depth. * - * @param int|string $key - * - * @return mixed + * @param int|string $key + * @param array $array */ - function array_deep_search($key, array $array) + function array_deep_search($key, array $array): mixed { if (isset($array[$key])) { return $array[$key]; @@ -78,9 +77,9 @@ function array_deep_search($key, array $array) * For information on multi-level array sorting, refer to Example #3 here: * https://www.php.net/manual/de/function.array-multisort.php * - * @param array $array the reference of the array to be sorted - * @param array $sortColumns an associative array of columns to sort - * after and their sorting flags + * @param array $array The reference of the array to be sorted + * @param array $sortColumns An associative array of columns to sort + * after and their sorting flags */ function array_sort_by_multiple_keys(array &$array, array $sortColumns): bool { @@ -129,10 +128,10 @@ function array_sort_by_multiple_keys(array &$array, array $sortColumns): bool /** * Flatten a multidimensional array using dots as separators. * - * @param iterable $array The multi-dimensional array - * @param string $id Something to initially prepend to the flattened keys + * @param iterable $array The multi-dimensional array + * @param string $id Something to initially prepend to the flattened keys * - * @return array The flattened array + * @return array The flattened array */ function array_flatten_with_dots(iterable $array, string $id = ''): array { @@ -156,11 +155,11 @@ function array_flatten_with_dots(iterable $array, string $id = ''): array /** * Groups all rows by their index values. Result's depth equals number of indexes * - * @param array $array Data array (i.e. from query result) - * @param array $indexes Indexes to group by. Dot syntax used. Returns $array if empty - * @param bool $includeEmpty If true, null and '' are also added as valid keys to group + * @param array $array Data array (i.e. from query result) + * @param list $indexes Indexes to group by. Dot syntax used. Returns $array if empty + * @param bool $includeEmpty If true, null and '' are also added as valid keys to group * - * @return array Result array where rows are grouped together by indexes values. + * @return array Result array where rows are grouped together by indexes values. */ function array_group_by(array $array, array $indexes, bool $includeEmpty = false): array { diff --git a/system/Helpers/cookie_helper.php b/system/Helpers/cookie_helper.php index e3b10a1b60f6..3d3758278b8d 100644 --- a/system/Helpers/cookie_helper.php +++ b/system/Helpers/cookie_helper.php @@ -79,7 +79,7 @@ function set_cookie( * * @see \CodeIgniter\HTTP\IncomingRequest::getCookie() */ - function get_cookie($index, bool $xssClean = false, ?string $prefix = '') + function get_cookie($index, bool $xssClean = false, ?string $prefix = ''): array|string|null { if ($prefix === '') { $cookie = config(CookieConfig::class); diff --git a/system/Helpers/filesystem_helper.php b/system/Helpers/filesystem_helper.php index da6acabb039e..7c42bd5c65f8 100644 --- a/system/Helpers/filesystem_helper.php +++ b/system/Helpers/filesystem_helper.php @@ -24,9 +24,10 @@ * directory will be mapped as well. * * @param string $sourceDir Path to source - * @param int $directoryDepth Depth of directories to traverse - * (0 = fully recursive, 1 = current dir, etc) + * @param int $directoryDepth Depth of directories to traverse (0 = fully recursive, 1 = current dir, etc) * @param bool $hidden Whether to show hidden files + * + * @return array|string> */ function directory_map(string $sourceDir, int $directoryDepth = 0, bool $hidden = false): array { @@ -155,8 +156,9 @@ function write_file(string $path, string $data, string $mode = 'wb'): bool */ function delete_files(string $path, bool $delDir = false, bool $htdocs = false, bool $hidden = false): bool { - $path = realpath($path) ?: $path; - $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $realPath = realpath($path); + $path = $realPath === false ? $path : $realPath; + $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; try { foreach (new RecursiveIteratorIterator( @@ -199,6 +201,8 @@ function delete_files(string $path, bool $delDir = false, bool $htdocs = false, * @param bool|null $includePath Whether to include the path as part of the filename; false for no path, null for a relative path, true for full path * @param bool $hidden Whether to include hidden files (files beginning with a period) * @param bool $includeDir Whether to include directories + * + * @return list */ function get_filenames( string $sourceDir, @@ -208,7 +212,8 @@ function get_filenames( ): array { $files = []; - $sourceDir = realpath($sourceDir) ?: $sourceDir; + $realPath = realpath($sourceDir); + $sourceDir = $realPath === false ? $sourceDir : $realPath; $sourceDir = rtrim($sourceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; try { @@ -281,8 +286,11 @@ function get_dir_file_info(string $sourceDir, bool $topLevelOnly = true, bool $r if (is_dir($sourceDir . $file) && $file[0] !== '.' && $topLevelOnly === false) { get_dir_file_info($sourceDir . $file . DIRECTORY_SEPARATOR, $topLevelOnly, true); } elseif ($file[0] !== '.') { - $fileData[$file] = get_file_info($sourceDir . $file); - $fileData[$file]['relative_path'] = $relativePath; + $info = get_file_info($sourceDir . $file); + if ($info !== null) { + $fileData[$file] = $info; + $fileData[$file]['relative_path'] = $relativePath; + } } } @@ -318,7 +326,7 @@ function get_dir_file_info(string $sourceDir, bool $topLevelOnly = true, bool $r * fileperms?: int * }|null */ - function get_file_info(string $file, $returnedValues = ['name', 'server_path', 'size', 'date']) + function get_file_info(string $file, $returnedValues = ['name', 'server_path', 'size', 'date']): ?array { if (! is_file($file)) { return null; diff --git a/system/Helpers/form_helper.php b/system/Helpers/form_helper.php index 6fa4777e2454..dee1f001365b 100644 --- a/system/Helpers/form_helper.php +++ b/system/Helpers/form_helper.php @@ -23,9 +23,9 @@ * * Creates the opening portion of the form. * - * @param string $action the URI segments of the form destination - * @param array|string $attributes a key/value pair of attributes, or string representation - * @param array $hidden a key/value pair hidden data + * @param string $action The URI segments of the form destination + * @param array|string $attributes A key/value pair of attributes, or string representation + * @param array $hidden A key/value pair hidden data */ function form_open(string $action = '', $attributes = [], array $hidden = []): string { @@ -80,9 +80,9 @@ function form_open(string $action = '', $attributes = [], array $hidden = []): s * * Creates the opening portion of the form, but with "multipart/form-data". * - * @param string $action The URI segments of the form destination - * @param array|string $attributes A key/value pair of attributes, or the same as a string - * @param array $hidden A key/value pair hidden data + * @param string $action The URI segments of the form destination + * @param array|string $attributes A key/value pair of attributes, or the same as a string + * @param array $hidden A key/value pair hidden data */ function form_open_multipart(string $action = '', $attributes = [], array $hidden = []): string { @@ -103,8 +103,8 @@ function form_open_multipart(string $action = '', $attributes = [], array $hidde * Generates hidden fields. You can pass a simple key/value string or * an associative array with multiple values. * - * @param array|string $name Field name or associative array to create multiple fields - * @param array|string $value Field value + * @param array|string $name Field name or associative array to create multiple fields + * @param array|string|null $value Field value */ function form_hidden($name, $value = '', bool $recursing = false): string { @@ -140,8 +140,8 @@ function form_hidden($name, $value = '', bool $recursing = false): string * Text Input Field. If 'type' is passed in the $type field, it will be * used as the input type, for making 'email', 'phone', etc input fields. * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_input($data = '', string $value = '', $extra = '', string $type = 'text'): string { @@ -161,8 +161,8 @@ function form_input($data = '', string $value = '', $extra = '', string $type = * * Identical to the input function but adds the "password" type * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_password($data = '', string $value = '', $extra = ''): string { @@ -181,8 +181,8 @@ function form_password($data = '', string $value = '', $extra = ''): string * * Identical to the input function but adds the "file" type * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_upload($data = '', string $value = '', $extra = ''): string { @@ -205,8 +205,8 @@ function form_upload($data = '', string $value = '', $extra = ''): string /** * Textarea field * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_textarea($data = '', string $value = '', $extra = ''): string { @@ -241,8 +241,10 @@ function form_textarea($data = '', string $value = '', $extra = ''): string /** * Multi-select menu * - * @param array|string $name - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $name + * @param array|string> $options + * @param array $selected + * @param array|object|string $extra String, array, object that can be cast to array */ function form_multiselect($name = '', array $options = [], array $selected = [], $extra = ''): string { @@ -260,10 +262,10 @@ function form_multiselect($name = '', array $options = [], array $selected = [], /** * Drop-down Menu * - * @param array|string $data - * @param array|string $options - * @param array|string $selected - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|string>|string $options + * @param array|string $selected + * @param array|object|string $extra String, array, object that can be cast to array */ function form_dropdown($data = '', $options = [], $selected = [], $extra = ''): string { @@ -348,8 +350,8 @@ function form_dropdown($data = '', $options = [], $selected = [], $extra = ''): /** * Checkbox Field * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_checkbox($data = '', string $value = '', bool $checked = false, $extra = ''): string { @@ -381,8 +383,8 @@ function form_checkbox($data = '', string $value = '', bool $checked = false, $e /** * Radio Button * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_radio($data = '', string $value = '', bool $checked = false, $extra = ''): string { @@ -399,8 +401,8 @@ function form_radio($data = '', string $value = '', bool $checked = false, $extr /** * Submit Button * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_submit($data = '', string $value = '', $extra = ''): string { @@ -412,8 +414,8 @@ function form_submit($data = '', string $value = '', $extra = ''): string /** * Reset Button * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_reset($data = '', string $value = '', $extra = ''): string { @@ -425,8 +427,8 @@ function form_reset($data = '', string $value = '', $extra = ''): string /** * Form Button * - * @param array|string $data - * @param array|object|string $extra string, array, object that can be cast to array + * @param array|string $data + * @param array|object|string $extra String, array, object that can be cast to array */ function form_button($data = '', string $content = '', $extra = ''): string { @@ -450,9 +452,9 @@ function form_button($data = '', string $content = '', $extra = ''): string /** * Form Label Tag * - * @param string $labelText The text to appear onscreen - * @param string $id The id the label applies to - * @param array $attributes Additional attributes + * @param string $labelText The text to appear onscreen + * @param string $id The id the label applies to + * @param array $attributes Additional attributes */ function form_label(string $labelText = '', string $id = '', array $attributes = []): string { @@ -477,6 +479,8 @@ function form_label(string $labelText = '', string $id = '', array $attributes = * The element specifies a list of pre-defined options for an element. * Users will see a drop-down list of pre-defined options as they input data. * The list attribute of the element, must refer to the id attribute of the element. + * + * @param list $options */ function form_datalist(string $name, string $value, array $options): string { @@ -506,8 +510,8 @@ function form_datalist(string $name, string $value, array $options): string * Used to produce
text. To close fieldset * use form_fieldset_close() * - * @param string $legendText The legend text - * @param array $attributes Additional attributes + * @param string $legendText The legend text + * @param array $attributes Additional attributes */ function form_fieldset(string $legendText = '', array $attributes = []): string { @@ -554,16 +558,14 @@ function form_close(string $extra = ''): string * * @return list|string */ - function set_value(string $field, $default = '', bool $htmlEscape = true) + function set_value(string $field, $default = '', bool $htmlEscape = true): array|string { $request = service('request'); // Try any old input data we may have first $value = $request->getOldInput($field); - if ($value === null) { - $value = $request->getPost($field) ?? $default; - } + $value ??= $request->getPost($field) ?? $default; return ($htmlEscape) ? esc($value) : $value; } @@ -582,9 +584,7 @@ function set_select(string $field, string $value = '', bool $default = false): s // Try any old input data we may have first $input = $request->getOldInput($field); - if ($input === null) { - $input = $request->getPost($field); - } + $input ??= $request->getPost($field); if ($input === null) { return $default ? ' selected="selected"' : ''; @@ -618,9 +618,7 @@ function set_checkbox(string $field, string $value = '', bool $default = false): // Try any old input data we may have first $input = $request->getOldInput($field); - if ($input === null) { - $input = $request->getPost($field); - } + $input ??= $request->getPost($field); if (is_array($input)) { // Note: in_array('', array(0)) returns TRUE, do not use it @@ -637,7 +635,7 @@ function set_checkbox(string $field, string $value = '', bool $default = false): $hasOldInput = $session->has('_ci_old_input'); // Unchecked checkbox and radio inputs are not even submitted by browsers ... - if ((string) $input === '0' || ! empty($request->getPost()) || $hasOldInput) { + if ((string) $input === '0' || $request->getPost() !== [] || $hasOldInput) { return ($input === $value) ? ' checked="checked"' : ''; } @@ -697,7 +695,7 @@ function set_radio(string $field, string $value = '', bool $default = false): st * * @return array */ - function validation_errors() + function validation_errors(): array { $errors = session('_ci_validation_errors'); @@ -770,8 +768,8 @@ function validation_show_error(string $field, string $template = 'single'): stri * * @internal * - * @param array|string $attributes List of attributes - * @param array $default Default values + * @param array|string $attributes List of attributes + * @param array $default Default values */ function parse_form_attributes($attributes, array $default): string { diff --git a/system/Helpers/html_helper.php b/system/Helpers/html_helper.php index 9dcf82536eeb..67441f935c11 100644 --- a/system/Helpers/html_helper.php +++ b/system/Helpers/html_helper.php @@ -24,8 +24,8 @@ * Generates an HTML unordered list from a single or * multidimensional array. * - * @param array $list List entries - * @param array|object|string $attributes HTML attributes string, array, object + * @param array|string> $list List entries + * @param array|object|string $attributes HTML attributes string, array, object */ function ul(array $list, $attributes = ''): string { @@ -39,8 +39,8 @@ function ul(array $list, $attributes = ''): string * * Generates an HTML ordered list from a single or multidimensional array. * - * @param array $list List entries - * @param array|object|string $attributes HTML attributes string, array, object + * @param array|string> $list List entries + * @param array|object|string $attributes HTML attributes string, array, object */ function ol(array $list, $attributes = ''): string { @@ -54,8 +54,8 @@ function ol(array $list, $attributes = ''): string * * Generates an HTML ordered list from a single or multidimensional array. * - * @param array $list List entries - * @param array|object|string $attributes HTML attributes string, array, object + * @param array|string> $list List entries + * @param array|object|string $attributes HTML attributes string, array, object */ function _list(string $type = 'ul', $list = [], $attributes = '', int $depth = 0): string { @@ -93,21 +93,17 @@ function _list(string $type = 'ul', $list = [], $attributes = '', int $depth = 0 * * Generates an image element * - * @param array|string $src Image source URI, or array of attributes and values - * @param bool $indexPage Should `Config\App::$indexPage` be added to the source path - * @param array|object|string $attributes Additional HTML attributes + * @param array|string $src Image source URI, or array of attributes and values + * @param bool $indexPage Should `Config\App::$indexPage` be added to the source path + * @param array|object|string $attributes Additional HTML attributes */ function img($src = '', bool $indexPage = false, $attributes = ''): string { if (! is_array($src)) { $src = ['src' => $src]; } - if (! isset($src['src'])) { - $src['src'] = $attributes['src'] ?? ''; - } - if (! isset($src['alt'])) { - $src['alt'] = $attributes['alt'] ?? ''; - } + $src['src'] ??= $attributes['src'] ?? ''; + $src['alt'] ??= $attributes['alt'] ?? ''; $img = '|string $src Script source or an array of attributes + * @param bool $indexPage Should `Config\App::$indexPage` be added to the JS path */ function script_tag($src = '', bool $indexPage = false): string { @@ -287,10 +283,11 @@ function link_tag( * Generates a video element to embed videos. The video element can * contain one or more video sources * - * @param array|string $src Either a source string or an array of sources - * @param string $unsupportedMessage The message to display if the media tag is not supported by the browser - * @param string $attributes HTML attributes - * @param bool $indexPage Should `Config\App::$indexPage` be added to the source path + * @param list|string $src Either a source string or an array of sources + * @param string $unsupportedMessage The message to display if the media tag is not supported by the browser + * @param string $attributes HTML attributes + * @param list $tracks Track elements + * @param bool $indexPage Should `Config\App::$indexPage` be added to the source path */ function video($src, string $unsupportedMessage = '', string $attributes = '', array $tracks = [], bool $indexPage = false): string { @@ -334,10 +331,11 @@ function video($src, string $unsupportedMessage = '', string $attributes = '', a * * Generates an audio element to embed sounds * - * @param array|string $src Either a source string or an array of sources - * @param string $unsupportedMessage The message to display if the media tag is not supported by the browser. - * @param string $attributes HTML attributes - * @param bool $indexPage Should `Config\App::$indexPage` be added to the source path + * @param list|string $src Either a source string or an array of sources + * @param string $unsupportedMessage The message to display if the media tag is not supported by the browser. + * @param string $attributes HTML attributes + * @param list $tracks Track elements + * @param bool $indexPage Should `Config\App::$indexPage` be added to the source path */ function audio($src, string $unsupportedMessage = '', string $attributes = '', array $tracks = [], bool $indexPage = false): string { @@ -377,7 +375,9 @@ function audio($src, string $unsupportedMessage = '', string $attributes = '', a /** * Generate media based tag * - * @param string $unsupportedMessage The message to display if the media tag is not supported by the browser. + * @param list $types Source elements + * @param string $unsupportedMessage The message to display if the media tag is not supported by the browser. + * @param list $tracks Track elements */ function _media(string $name, array $types = [], string $unsupportedMessage = '', string $attributes = '', array $tracks = []): string { @@ -466,10 +466,11 @@ function track(string $src, string $kind, string $srcLanguage, string $label): s * as either image or a resource plugin such as audio, video, * Java applets, ActiveX, PDF and Flash * - * @param string $data A resource URL - * @param string $type Content-type of the resource - * @param string $attributes HTML attributes - * @param bool $indexPage Should `Config\App::$indexPage` be added to the data path + * @param string $data A resource URL + * @param string $type Content-type of the resource + * @param string $attributes HTML attributes + * @param list $params Param elements + * @param bool $indexPage Should `Config\App::$indexPage` be added to the data path */ function object(string $data, string $type = 'unknown', string $attributes = '', array $params = [], bool $indexPage = false): string { @@ -539,10 +540,8 @@ function embed(string $src, string $type = 'unknown', string $attributes = '', b if (! function_exists('_has_protocol')) { /** * Test the protocol of a URI. - * - * @return false|int */ - function _has_protocol(string $url) + function _has_protocol(string $url): false|int { return preg_match('#^([a-z]+:)?//#i', $url); } diff --git a/system/Helpers/kint_helper.php b/system/Helpers/kint_helper.php index 8d8e73bfc92f..e311d9ff7cc8 100644 --- a/system/Helpers/kint_helper.php +++ b/system/Helpers/kint_helper.php @@ -18,7 +18,7 @@ /** * Prints a Kint debug report and exits. * - * @param array $vars + * @param mixed ...$vars * * @return never * @@ -38,11 +38,9 @@ function dd(...$vars): void /** * dd function * - * @param array $vars - * - * @return int + * @param mixed ...$vars */ - function dd(...$vars) + function dd(...$vars): int { return 0; } @@ -54,11 +52,9 @@ function dd(...$vars) /** * d function * - * @param array $vars - * - * @return int + * @param mixed ...$vars */ - function d(...$vars) + function d(...$vars): int { return 0; } @@ -77,10 +73,8 @@ function trace(): void } else { /** * Generic trace function in case that Kint is not loaded. - * - * @return int */ - function trace() + function trace(): int { return 0; } diff --git a/system/Helpers/number_helper.php b/system/Helpers/number_helper.php index bf577ed293f6..2f4f6f9bed74 100644 --- a/system/Helpers/number_helper.php +++ b/system/Helpers/number_helper.php @@ -19,16 +19,14 @@ /** * Formats a numbers as bytes, based on size, and adds the appropriate suffix * - * @param int|string $num Will be cast as int + * @param float|int|string $num Will be cast as int * @param non-empty-string|null $locale [optional] - * - * @return bool|string */ - function number_to_size($num, int $precision = 1, ?string $locale = null) + function number_to_size($num, int $precision = 1, ?string $locale = null): bool|string { - // Strip any formatting & ensure numeric input try { - // @phpstan-ignore-next-line + // Strip any formatting & ensure numeric input + // @phpstan-ignore binaryOp.invalid $num = 0 + str_replace(',', '', (string) $num); } catch (ErrorException) { // Catch "Warning: A non-numeric value encountered" @@ -76,10 +74,8 @@ function number_to_size($num, int $precision = 1, ?string $locale = null) * @param int|string $num Will be cast as int * @param int $precision [optional] The optional number of decimal digits to round to. * @param non-empty-string|null $locale [optional] - * - * @return bool|string */ - function number_to_amount($num, int $precision = 0, ?string $locale = null) + function number_to_amount($num, int $precision = 0, ?string $locale = null): bool|string { // Strip any formatting & ensure numeric input try { @@ -134,6 +130,8 @@ function number_to_currency(float $num, string $currency, ?string $locale = null /** * A general purpose, locale-aware, number_format method. * Used by all of the functions of the number_helper. + * + * @param array $options */ function format_number(float $num, int $precision = 1, ?string $locale = null, array $options = []): string { diff --git a/system/Helpers/test_helper.php b/system/Helpers/test_helper.php index f86ca468b0cc..b8cbe0462350 100644 --- a/system/Helpers/test_helper.php +++ b/system/Helpers/test_helper.php @@ -22,13 +22,13 @@ /** * Creates a single item using Fabricator. * - * @param Model|object|string $model Instance or name of the model - * @param array|null $overrides Overriding data to pass to Fabricator::setOverrides() - * @param bool $persist + * @param Model|object|string $model Instance or name of the model + * @param array|null $overrides Overriding data to pass to Fabricator::setOverrides() + * @param bool $persist * - * @return array|object + * @return array|object */ - function fake($model, ?array $overrides = null, $persist = true) + function fake($model, ?array $overrides = null, $persist = true): array|object { $fabricator = new Fabricator($model); @@ -49,21 +49,19 @@ function fake($model, ?array $overrides = null, $persist = true) * Used within our test suite to mock certain system tools. * * @param string $className Fully qualified class name - * - * @return object */ - function mock(string $className) + function mock(string $className): object { $mockClass = $className::$mockClass; $mockService = $className::$mockServiceName ?? ''; - if (empty($mockClass) || ! class_exists($mockClass)) { + if ($mockClass === '' || ! class_exists($mockClass)) { throw TestException::forInvalidMockClass($mockClass); } $mock = new $mockClass(); - if (! empty($mockService)) { + if ($mockService !== '') { Services::injectMock($mockService, $mock); } diff --git a/system/Helpers/text_helper.php b/system/Helpers/text_helper.php index b8298953bdd3..09d14abbe2ee 100644 --- a/system/Helpers/text_helper.php +++ b/system/Helpers/text_helper.php @@ -174,9 +174,9 @@ function entities_to_ascii(string $str, bool $all = true): string * matched words will be converted to #### or to the replacement * word you've submitted. * - * @param string $str the text string - * @param array $censored the array of censored words - * @param string $replacement the optional replacement value + * @param string $str The text string + * @param list $censored The array of censored words + * @param string $replacement The optional replacement value */ function word_censor(string $str, array $censored, string $replacement = ''): string { @@ -383,7 +383,9 @@ function word_wrap(string $str, int $charlim = 76): string $temp = ''; - while (mb_strlen($line) > $charlim) { + // A limit of 1 can't be used to split a word (each piece needs room for + // the continuation), so the word is kept whole, like a long URL. + while ($charlim > 1 && mb_strlen($line) > $charlim) { // If the over-length word is a URL we won't wrap it if (preg_match('!\[url.+\]|://|www\.!', $line)) { break; @@ -454,11 +456,11 @@ function ellipsize(string $str, int $maxLength, $position = 1, string $ellipsis * * Removes slashes contained in a string or in an array * - * @param array|string $str string or array + * @param array|string $str string or array * - * @return array|string string or array + * @return array|string string or array */ - function strip_slashes($str) + function strip_slashes($str): array|string { if (! is_array($str)) { return stripslashes($str); diff --git a/system/Helpers/url_helper.php b/system/Helpers/url_helper.php index f8cb6b2540f9..c08187c6c40b 100644 --- a/system/Helpers/url_helper.php +++ b/system/Helpers/url_helper.php @@ -25,11 +25,11 @@ /** * Returns a site URL as defined by the App config. * - * @param array|string $relativePath URI string or array of URI segments. - * @param string|null $scheme URI scheme. E.g., http, ftp. If empty - * string '' is set, a protocol-relative - * link is returned. - * @param App|null $config Alternate configuration to use. + * @param list|string $relativePath URI string or array of URI segments. + * @param string|null $scheme URI scheme. E.g., http, ftp. If empty + * string '' is set, a protocol-relative + * link is returned. + * @param App|null $config Alternate configuration to use. */ function site_url($relativePath = '', ?string $scheme = null, ?App $config = null): string { @@ -46,10 +46,10 @@ function site_url($relativePath = '', ?string $scheme = null, ?App $config = nul * Returns the base URL as defined by the App config. * Base URLs are trimmed site URLs without the index page. * - * @param array|string $relativePath URI string or array of URI segments. - * @param string|null $scheme URI scheme. E.g., http, ftp. If empty - * string '' is set, a protocol-relative - * link is returned. + * @param list|string $relativePath URI string or array of URI segments. + * @param string|null $scheme URI scheme. E.g., http, ftp. If empty + * string '' is set, a protocol-relative + * link is returned. */ function base_url($relativePath = '', ?string $scheme = null): string { @@ -68,10 +68,10 @@ function base_url($relativePath = '', ?string $scheme = null): string * @param bool $returnObject True to return an object instead of a string * @param IncomingRequest|null $request A request to use when retrieving the path * - * @return string|URI When returning string, the query and fragment parts are removed. - * When returning URI, the query and fragment parts are preserved. + * @return ($returnObject is true ? URI : string) When returning string, the query and fragment parts are removed. + * When returning URI, the query and fragment parts are preserved. */ - function current_url(bool $returnObject = false, ?IncomingRequest $request = null) + function current_url(bool $returnObject = false, ?IncomingRequest $request = null): string|URI { $request ??= service('request'); /** @var CLIRequest|IncomingRequest $request */ @@ -88,9 +88,9 @@ function current_url(bool $returnObject = false, ?IncomingRequest $request = nul * If that's not available, however, we'll use a sanitized url from $_SERVER['HTTP_REFERER'] * which can be set by the user so is untrusted and not set by certain browsers/servers. * - * @return string|URI + * @return ($returnObject is true ? URI : string) */ - function previous_url(bool $returnObject = false) + function previous_url(bool $returnObject = false): string|URI { // Grab from the session first, if we have it, // since it's more reliable and safer. @@ -146,10 +146,10 @@ function index_page(?App $altConfig = null): string * * Creates an anchor based on the local URL. * - * @param array|string $uri URI string or array of URI segments - * @param string $title The link title - * @param array|object|string $attributes Any attributes - * @param App|null $altConfig Alternate configuration to use + * @param list|string $uri URI string or array of URI segments + * @param string $title The link title + * @param array|object|string $attributes Any attributes + * @param App|null $altConfig Alternate configuration to use */ function anchor($uri = '', string $title = '', $attributes = '', ?App $altConfig = null): string { @@ -179,10 +179,10 @@ function anchor($uri = '', string $title = '', $attributes = '', ?App $altConfig * Creates an anchor based on the local URL. The link * opens a new window based on the attributes specified. * - * @param string $uri the URL - * @param string $title the link title - * @param array|false|object|string $attributes any attributes - * @param App|null $altConfig Alternate configuration to use + * @param string $uri the URL + * @param string $title the link title + * @param array|false|object|string $attributes any attributes + * @param App|null $altConfig Alternate configuration to use */ function anchor_popup($uri = '', string $title = '', $attributes = false, ?App $altConfig = null): string { @@ -205,7 +205,7 @@ function anchor_popup($uri = '', string $title = '', $attributes = false, ?App $ // Ref: http://www.w3schools.com/jsref/met_win_open.asp $windowName = '_blank'; - } elseif (! empty($attributes['window_name'])) { + } elseif (($attributes['window_name'] ?? '') !== '') { $windowName = $attributes['window_name']; unset($attributes['window_name']); } else { @@ -231,9 +231,9 @@ function anchor_popup($uri = '', string $title = '', $attributes = false, ?App $ /** * Mailto Link * - * @param string $email the email address - * @param string $title the link title - * @param array|object|string $attributes any attributes + * @param string $email the email address + * @param string $title the link title + * @param array|object|string $attributes any attributes */ function mailto(string $email, string $title = '', $attributes = ''): string { @@ -251,9 +251,9 @@ function mailto(string $email, string $title = '', $attributes = ''): string * * Create a spam-protected mailto link written in Javascript * - * @param string $email the email address - * @param string $title the link title - * @param array|object|string $attributes any attributes + * @param string $email the email address + * @param string $title the link title + * @param array|object|string $attributes any attributes */ function safe_mailto(string $email, string $title = '', $attributes = ''): string { @@ -423,7 +423,7 @@ function prep_url(string $str = '', bool $secure = false): string // force replace http:// with https:// if ($secure) { - $str = preg_replace('/^(?:http):/i', 'https:', $str); + return preg_replace('/^(?:http):/i', 'https:', $str); } return $str; @@ -546,9 +546,7 @@ function url_is(string $path): bool */ function parse_subdomain(?string $host = null): string { - if ($host === null) { - $host = service('request')->getUri()->getHost(); - } + $host ??= service('request')->getUri()->getHost(); // Handle localhost and IP addresses - they don't have subdomains if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) { diff --git a/system/Honeypot/Exceptions/HoneypotException.php b/system/Honeypot/Exceptions/HoneypotException.php index 5185f6d3c843..c8eedeb311f6 100644 --- a/system/Honeypot/Exceptions/HoneypotException.php +++ b/system/Honeypot/Exceptions/HoneypotException.php @@ -14,8 +14,9 @@ namespace CodeIgniter\Honeypot\Exceptions; use CodeIgniter\Exceptions\ConfigException; +use CodeIgniter\Exceptions\HTTPExceptionInterface; -class HoneypotException extends ConfigException +class HoneypotException extends ConfigException implements HTTPExceptionInterface { /** * Thrown when the template value of config is empty. @@ -24,7 +25,7 @@ class HoneypotException extends ConfigException */ public static function forNoTemplate() { - return new static(lang('Honeypot.noTemplate')); + return new static(lang('Honeypot.noTemplate'), 500); } /** @@ -34,7 +35,7 @@ public static function forNoTemplate() */ public static function forNoNameField() { - return new static(lang('Honeypot.noNameField')); + return new static(lang('Honeypot.noNameField'), 500); } /** @@ -46,7 +47,7 @@ public static function forNoNameField() */ public static function forNoHiddenValue() { - return new static(lang('Honeypot.noHiddenValue')); + return new static(lang('Honeypot.noHiddenValue'), 500); } /** @@ -56,6 +57,6 @@ public static function forNoHiddenValue() */ public static function isBot() { - return new static(lang('Honeypot.theClientIsABot')); + return new static(lang('Honeypot.theClientIsABot'), 403); } } diff --git a/system/Honeypot/Honeypot.php b/system/Honeypot/Honeypot.php index a816bdd1ed60..4a40a8ec232a 100644 --- a/system/Honeypot/Honeypot.php +++ b/system/Honeypot/Honeypot.php @@ -64,7 +64,7 @@ public function hasContent(RequestInterface $request) { assert($request instanceof IncomingRequest); - return ! empty($request->getPost($this->config->name)); + return ! in_array($request->getPost($this->config->name), [null, false, 0, 0.0, '', '0', []], true); } /** @@ -111,7 +111,7 @@ protected function prepareTemplate(string $template): string $template = str_ireplace('{name}', $this->config->name, $template); if ($this->config->hidden) { - $template = str_ireplace('{template}', $template, $this->config->container); + return str_ireplace('{template}', $template, $this->config->container); } return $template; diff --git a/system/HotReloader/DirectoryHasher.php b/system/HotReloader/DirectoryHasher.php index ed82d98b894a..ab9ab7a017a4 100644 --- a/system/HotReloader/DirectoryHasher.php +++ b/system/HotReloader/DirectoryHasher.php @@ -39,6 +39,8 @@ public function hash(): string /** * Generates an array of md5 hashes for all directories that are * watched by the Hot Reloader, as defined in the Config\Toolbar. + * + * @return array */ public function hashApp(): array { diff --git a/system/HotReloader/HotReloader.php b/system/HotReloader/HotReloader.php index 305e641af04e..e1100e898436 100644 --- a/system/HotReloader/HotReloader.php +++ b/system/HotReloader/HotReloader.php @@ -61,6 +61,8 @@ public function run(): void /** * Send an event to the browser. + * + * @param array $data */ private function sendEvent(string $event, array $data): void { diff --git a/system/HotReloader/IteratorFilter.php b/system/HotReloader/IteratorFilter.php index 983e1297a71b..1134f77665f1 100644 --- a/system/HotReloader/IteratorFilter.php +++ b/system/HotReloader/IteratorFilter.php @@ -24,6 +24,9 @@ */ final class IteratorFilter extends RecursiveFilterIterator implements RecursiveIterator { + /** + * @var list + */ private array $watchedExtensions = []; public function __construct(RecursiveIterator $iterator) diff --git a/system/I18n/Time.php b/system/I18n/Time.php index 255f879a90ec..cc254dba03ea 100644 --- a/system/I18n/Time.php +++ b/system/I18n/Time.php @@ -34,6 +34,7 @@ * @property-read string $quarter * @property-read string $second * @property-read int $timestamp + * @property-read string $timezoneName * @property-read bool $utc * @property-read string $weekOfMonth * @property-read string $weekOfYear diff --git a/system/I18n/TimeLegacy.php b/system/I18n/TimeLegacy.php index 034cab0dadcf..119fc3f5d050 100644 --- a/system/I18n/TimeLegacy.php +++ b/system/I18n/TimeLegacy.php @@ -23,22 +23,23 @@ * This is not immutable! Some methods are immutable, * but some methods can alter the state. * - * @property int $age read-only - * @property string $day read-only - * @property string $dayOfWeek read-only - * @property string $dayOfYear read-only - * @property bool $dst read-only - * @property string $hour read-only - * @property bool $local read-only - * @property string $minute read-only - * @property string $month read-only - * @property string $quarter read-only - * @property string $second read-only - * @property int $timestamp read-only - * @property bool $utc read-only - * @property string $weekOfMonth read-only - * @property string $weekOfYear read-only - * @property string $year read-only + * @property int $age read-only + * @property string $day read-only + * @property string $dayOfWeek read-only + * @property string $dayOfYear read-only + * @property bool $dst read-only + * @property string $hour read-only + * @property bool $local read-only + * @property string $minute read-only + * @property string $month read-only + * @property string $quarter read-only + * @property string $second read-only + * @property int $timestamp read-only + * @property string $timezoneName read-only + * @property bool $utc read-only + * @property string $weekOfMonth read-only + * @property string $weekOfYear read-only + * @property string $year read-only * * @phpstan-consistent-constructor * diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 04499668f5ed..16454c5f57b4 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -87,7 +87,7 @@ public function __construct(?string $time = null, $timezone = null, ?string $loc } } - $timezone = $timezone ?: date_default_timezone_get(); + $timezone = ($timezone === null || $timezone === '') ? date_default_timezone_get() : $timezone; $this->timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); // If the time string was a relative string (i.e. 'next Tuesday') @@ -144,7 +144,7 @@ public static function parse(string $datetime, $timezone = null, ?string $locale */ public static function today($timezone = null, ?string $locale = null) { - return new static(date('Y-m-d 00:00:00'), $timezone, $locale); + return (new static(null, $timezone, $locale))->setTime(0, 0, 0, 0); } /** @@ -158,7 +158,7 @@ public static function today($timezone = null, ?string $locale = null) */ public static function yesterday($timezone = null, ?string $locale = null) { - return new static(date('Y-m-d 00:00:00', strtotime('-1 day')), $timezone, $locale); + return (new static(null, $timezone, $locale))->modify('yesterday'); } /** @@ -172,7 +172,7 @@ public static function yesterday($timezone = null, ?string $locale = null) */ public static function tomorrow($timezone = null, ?string $locale = null) { - return new static(date('Y-m-d 00:00:00', strtotime('+1 day')), $timezone, $locale); + return (new static(null, $timezone, $locale))->modify('tomorrow'); } /** @@ -1157,7 +1157,7 @@ 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; @@ -1211,7 +1211,7 @@ public function __toString(): string * * @param string $name * - * @return array|bool|DateTimeInterface|DateTimeZone|int|IntlCalendar|self|string|null + * @return array|bool|DateTimeInterface|DateTimeZone|int|IntlCalendar|self|string|null */ public function __get($name) { @@ -1239,10 +1239,14 @@ public function __isset($name): bool /** * This is called when we unserialize the Time object. * - * @param array{date: string, timezone: string, timezone_type: int} $data + * @param array $data */ public function __unserialize(array $data): void { - parent::__construct($data['date'], new DateTimeZone($data['timezone'])); + $date = $data['date'] ?? null; + $timezone = $data['timezone'] ?? null; + assert(is_string($date) && is_string($timezone)); + + parent::__construct($date, new DateTimeZone($timezone)); } } diff --git a/system/Images/Handlers/BaseHandler.php b/system/Images/Handlers/BaseHandler.php index 9301b1573c23..906fc9f0b8e3 100644 --- a/system/Images/Handlers/BaseHandler.php +++ b/system/Images/Handlers/BaseHandler.php @@ -20,7 +20,11 @@ use Config\Images; /** - * Base image handling implementation + * Base image handling implementation. + * + * @template T of object + * + * @method string getPathname() */ abstract class BaseHandler implements ImageHandlerInterface { @@ -90,7 +94,7 @@ abstract class BaseHandler implements ImageHandlerInterface /** * Default options for text watermarking. * - * @var array + * @var array */ protected $textDefaults = [ 'fontPath' => null, @@ -110,7 +114,7 @@ abstract class BaseHandler implements ImageHandlerInterface /** * Image types with support for transparency. * - * @var array + * @var list */ protected $supportTransparency = [ IMAGETYPE_PNG, @@ -120,7 +124,7 @@ abstract class BaseHandler implements ImageHandlerInterface /** * Temporary image used by the different engines. * - * @var resource|null + * @var T|null */ protected $resource; @@ -193,7 +197,6 @@ protected function image(): Image throw ImageException::forMissingImage(); } - // Verify the loaded image is an Image instance if (! $this->image instanceof Image) { throw ImageException::forInvalidPath(); } @@ -203,7 +206,6 @@ protected function image(): Image throw ImageException::forFileNotSupported(); } - // Note that the image has been verified $this->verified = true; return $this->image; @@ -214,7 +216,7 @@ protected function image(): Image * Good for extending the system or doing things this library * is not intended to do. * - * @return resource + * @return T */ public function getResource() { @@ -246,7 +248,6 @@ public function withResource() */ public function resize(int $width, int $height, bool $maintainRatio = false, string $masterDim = 'auto') { - // If the target width/height match the source, then we have nothing to do here. if ($this->image()->origWidth === $width && $this->image()->origHeight === $height) { return $this; } @@ -316,28 +317,20 @@ public function convert(int $imageType) */ public function rotate(float $angle) { - // Allowed rotation values - $degs = [ - 90.0, - 180.0, - 270.0, - ]; + $degs = [90.0, 180.0, 270.0]; if (! in_array($angle, $degs, true)) { throw ImageException::forMissingAngle(); } - // cast angle as an int, for our use $angle = (int) $angle; - // Reassign the width and height if ($angle === 90 || $angle === 270) { $temp = $this->height; $this->width = $this->height; $this->height = $temp; } - // Call the Handler-specific version. $this->_rotate($angle); return $this; @@ -545,9 +538,7 @@ public function fit(int $width, ?int $height = null, string $position = 'center' [$cropWidth, $cropHeight] = $this->calcAspectRatio($width, $height, $origWidth, $origHeight); - if ($height === null) { - $height = (int) ceil(($width / $cropWidth) * $cropHeight); - } + $height ??= (int) ceil(($width / $cropWidth) * $cropHeight); [$x, $y] = $this->calcCropCoords($cropWidth, $cropHeight, $origWidth, $origHeight, $position); @@ -561,15 +552,15 @@ public function fit(int $width, ?int $height = null, string $position = 'center' * @param float|int|null $height * @param float|int $origWidth * @param float|int $origHeight + * + * @return array{0: float|int, 1: float|int} */ protected function calcAspectRatio($width, $height = null, $origWidth = 0, $origHeight = 0): array { - if (empty($origWidth) || empty($origHeight)) { + if (in_array($origWidth, [0, 0.0], true) || in_array($origHeight, [0, 0.0], true)) { throw new InvalidArgumentException('You must supply the parameters: origWidth, origHeight.'); } - // If $height is null, then we have it easy. - // Calc based on full image size and be done. if ($height === null) { $height = ($width / $origWidth) * $origHeight; @@ -604,6 +595,8 @@ protected function calcAspectRatio($width, $height = null, $origWidth = 0, $orig * @param float|int $origWidth * @param float|int $origHeight * @param string $position + * + * @return array{0: float|int, 1: float|int} */ protected function calcCropCoords($width, $height, $origWidth, $origHeight, $position): array { @@ -695,6 +688,8 @@ abstract protected function process(string $action); * Provide access to the Image class' methods if they don't exist * on the handler itself. * + * @param list $args + * * @return mixed */ public function __call(string $name, array $args = []) @@ -724,7 +719,6 @@ protected function reproportion() return; } - // Sanitize $this->width = (int) $this->width; $this->height = (int) $this->height; diff --git a/system/Images/Handlers/GDHandler.php b/system/Images/Handlers/GDHandler.php index 01c05384c744..f565ee317963 100644 --- a/system/Images/Handlers/GDHandler.php +++ b/system/Images/Handlers/GDHandler.php @@ -15,15 +15,16 @@ use CodeIgniter\Images\Exceptions\ImageException; use Config\Images; +use GdImage; /** * Image handler for GD package + * + * @extends BaseHandler */ class GDHandler extends BaseHandler { /** - * Constructor. - * * @param Images|null $config * * @throws ImageException @@ -43,17 +44,13 @@ public function __construct($config = null) */ protected function _rotate(int $angle): bool { - // Create the image handle $srcImg = $this->createImage(); - // Set the background color // This won't work with transparent PNG files so we are // going to have to figure out how to determine the color // of the alpha channel in a future release. - $white = imagecolorallocate($srcImg, 255, 255, 255); - // Rotate it! $destImg = imagerotate($srcImg, $angle, $white); $this->resource = $destImg; @@ -110,23 +107,23 @@ protected function _flip(string $direction) /** * Get GD version * - * @return mixed + * @return string */ public function getVersion() { - if (function_exists('gd_info')) { - $gdVersion = @gd_info(); - - return preg_replace('/\D/', '', $gdVersion['GD Version']); + if (! function_exists('gd_info')) { + return ''; // @codeCoverageIgnore } - return false; + $gdVersion = @gd_info(); + + return preg_replace('/\D/', '', $gdVersion['GD Version']) ?? ''; } /** * Resizes the image. * - * @return GDHandler + * @return $this */ public function _resize(bool $maintainRatio = false) { @@ -136,7 +133,7 @@ public function _resize(bool $maintainRatio = false) /** * Crops the image. * - * @return GDHandler + * @return $this */ public function _crop() { @@ -154,7 +151,6 @@ protected function process(string $action) $origHeight = $this->image()->origHeight; if ($action === 'crop') { - // Reassign the source width/height if cropping $origWidth = $this->width; $origHeight = $this->height; @@ -165,7 +161,6 @@ protected function process(string $action) $this->image()->origWidth = $this->width; } - // Create the image handle $src = $this->createImage(); if (function_exists('imagecreatetruecolor')) { @@ -178,7 +173,6 @@ protected function process(string $action) $dest = $create($this->width, $this->height); - // for png and webp we can actually preserve transparency if (in_array($this->image()->imageType, $this->supportTransparency, true)) { imagealphablending($dest, false); imagesavealpha($dest, true); @@ -209,7 +203,7 @@ public function save(?string $target = null, int $quality = 90): bool // If no new resource has been created, then we're // simply copy the existing one. - if (empty($this->resource) && $quality === 100) { + if ($this->resource === null && $quality === 100) { if ($original === null) { return true; } @@ -222,7 +216,6 @@ public function save(?string $target = null, int $quality = 90): bool $this->ensureResource(); - // for png and webp we can actually preserve transparency if (in_array($this->image()->imageType, $this->supportTransparency, true)) { imagepalettetotruecolor($this->resource); imagealphablending($this->resource, false); @@ -284,10 +277,7 @@ public function save(?string $target = null, int $quality = 90): bool /** * Create Image Resource * - * This simply creates an image resource handle - * based on the type of image being processed - * - * @return bool|resource + * @return GdImage */ protected function createImage(string $path = '', string $imageType = '') { @@ -311,13 +301,10 @@ protected function createImage(string $path = '', string $imageType = '') */ protected function ensureResource() { - if ($this->resource === null) { - // if valid image type, make corresponding image resource - $this->resource = $this->getImageResource( - $this->image()->getPathname(), - $this->image()->imageType, - ); - } + $this->resource ??= $this->getImageResource( + $this->image()->getPathname(), + $this->image()->imageType, + ); } /** @@ -326,7 +313,7 @@ protected function ensureResource() * @param string $path Image path * @param int $imageType Image type * - * @return bool|resource + * @return GdImage * * @throws ImageException */ @@ -338,32 +325,42 @@ protected function getImageResource(string $path, int $imageType) throw ImageException::forInvalidImageCreate(lang('Images.gifNotSupported')); } - return imagecreatefromgif($path); + $resource = imagecreatefromgif($path); + break; case IMAGETYPE_JPEG: if (! function_exists('imagecreatefromjpeg')) { throw ImageException::forInvalidImageCreate(lang('Images.jpgNotSupported')); } - return imagecreatefromjpeg($path); + $resource = imagecreatefromjpeg($path); + break; case IMAGETYPE_PNG: if (! function_exists('imagecreatefrompng')) { throw ImageException::forInvalidImageCreate(lang('Images.pngNotSupported')); } - return @imagecreatefrompng($path); + $resource = @imagecreatefrompng($path); + break; case IMAGETYPE_WEBP: if (! function_exists('imagecreatefromwebp')) { throw ImageException::forInvalidImageCreate(lang('Images.webpNotSupported')); } - return imagecreatefromwebp($path); + $resource = imagecreatefromwebp($path); + break; default: throw ImageException::forInvalidImageCreate('Ima'); } + + if ($resource === false) { + throw ImageException::forInvalidImageCreate($path); // @codeCoverageIgnore + } + + return $resource; } /** @@ -386,10 +383,7 @@ protected function _text(string $text, array $options = []) $options['hOffset'] *= -1; } - // Set font width and height - // These are calculated differently depending on - // whether we are using the true type font or not - if (! empty($options['fontPath'])) { + if (($options['fontPath'] ?? '') !== '') { if (function_exists('imagettfbbox')) { $temp = imagettfbbox($options['fontSize'], 0, $options['fontPath'], $text); $temp = $temp[2] - $temp[0]; @@ -408,11 +402,9 @@ protected function _text(string $text, array $options = []) $options['fontheight'] = $fontheight; $options['fontwidth'] = $fontwidth; - // Set base X and Y axis values $xAxis = $options['hOffset'] + $options['padding']; $yAxis = $options['vOffset'] + $options['padding']; - // Set vertical alignment if ($options['vAlign'] === 'middle') { // Don't apply padding when you're in the middle of the image. $yAxis += ($this->image()->origHeight / 2) + ($fontheight / 2) - $options['padding'] - $fontheight - $options['shadowOffset']; @@ -420,7 +412,6 @@ protected function _text(string $text, array $options = []) $yAxis = ($this->image()->origHeight - $fontheight - $options['shadowOffset'] - ($fontheight / 2)) - $yAxis; } - // Set horizontal alignment if ($options['hAlign'] === 'right') { $xAxis += ($this->image()->origWidth - ($fontwidth * strlen($text)) - $options['shadowOffset']) - (2 * $options['padding']); } elseif ($options['hAlign'] === 'center') { @@ -431,7 +422,6 @@ protected function _text(string $text, array $options = []) $options['yAxis'] = $yAxis; if ($options['withShadow']) { - // Offset from text $options['xShadow'] = $xAxis + $options['shadowOffset']; $options['yShadow'] = $yAxis + $options['shadowOffset']; @@ -444,17 +434,15 @@ protected function _text(string $text, array $options = []) /** * Handler-specific method for overlaying text on an image. * - * @param bool $isShadow Whether we are drawing the dropshadow or actual text + * @param array $options + * @param bool $isShadow Whether we are drawing the dropshadow or actual text + * + * @return void */ protected function textOverlay(string $text, array $options = [], bool $isShadow = false) { $src = $this->createImage(); - /* Set RGB values for shadow - * - * Get the rest of the string and split it into 2-length - * hex values: - */ $opacity = (int) ($options['opacity'] * 127); // Allow opacity to be applied to the text @@ -473,8 +461,7 @@ protected function textOverlay(string $text, array $options = [], bool $isShadow $xAxis = $isShadow ? $options['xShadow'] : $options['xAxis']; $yAxis = $isShadow ? $options['yShadow'] : $options['yAxis']; - // Add the shadow to the source image - if (! empty($options['fontPath'])) { + if (($options['fontPath'] ?? '') !== '') { // We have to add fontheight because imagettftext locates the bottom left corner, not top-left corner. imagettftext($src, $options['fontSize'], 0, (int) $xAxis, (int) ($yAxis + $options['fontheight']), $color, $options['fontPath'], $text); } else { diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index 1472936d071b..3215c84f1ab3 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -24,16 +24,11 @@ /** * Image handler for Imagick extension. + * + * @extends BaseHandler */ class ImageMagickHandler extends BaseHandler { - /** - * Stores Imagick instance. - * - * @var Imagick|null - */ - protected $resource; - /** * Constructor. * @@ -60,14 +55,12 @@ public function __construct($config = null) protected function ensureResource() { if (! $this->resource instanceof Imagick) { - // Verify that we have a valid image $this->image(); try { $this->resource = new Imagick(); $this->resource->readImage($this->image()->getPathname()); - // Check for valid image if ($this->resource->getImageWidth() === 0 || $this->resource->getImageHeight() === 0) { throw ImageException::forInvalidImageCreate($this->image()->getPathname()); } @@ -119,12 +112,10 @@ protected function process(string $action, int $quality = 100) $yAxis, ); - // Reset canvas to cropped size $this->resource->setImagePage(0, 0, 0, 0); break; } - // Handle transparency for supported image types if (in_array($this->image()->imageType, $this->supportTransparency, true) && $this->resource->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED) { $this->resource->setImageAlphaChannel(Imagick::ALPHACHANNEL_OPAQUE); @@ -146,7 +137,6 @@ protected function process(string $action, int $quality = 100) public function _resize(bool $maintainRatio = false) { if ($maintainRatio) { - // If maintaining a ratio, we need a custom approach $this->ensureResource(); // Use thumbnailImage which preserves an aspect ratio @@ -155,7 +145,6 @@ public function _resize(bool $maintainRatio = false) return $this; } - // Use the common process() method for normal resizing return $this->process('resize'); } @@ -168,7 +157,6 @@ public function _resize(bool $maintainRatio = false) */ public function _crop() { - // Use the common process() method for cropping $result = $this->process('crop'); // Handle a case where crop dimensions exceed the original image size @@ -177,15 +165,12 @@ public function _crop() $imgHeight = $this->resource->getImageHeight(); if ($this->xAxis >= $imgWidth || $this->yAxis >= $imgHeight) { - // Create transparent background $background = new Imagick(); $background->newImage($this->width, $this->height, new ImagickPixel('transparent')); $background->setImageFormat($this->resource->getImageFormat()); - // Composite our image on the background $background->compositeImage($this->resource, Imagick::COMPOSITE_OVER, 0, 0); - // Replace our resource $this->resource = $background; } } @@ -205,11 +190,9 @@ protected function _rotate(int $angle) { $this->ensureResource(); - // Create transparent background $this->resource->setImageBackgroundColor(new ImagickPixel('transparent')); $this->resource->rotateImage(new ImagickPixel('transparent'), $angle); - // Reset canvas dimensions $this->resource->setImagePage($this->resource->getImageWidth(), $this->resource->getImageHeight(), 0, 0); return $this; @@ -226,10 +209,8 @@ protected function _flatten(int $red = 255, int $green = 255, int $blue = 255) { $this->ensureResource(); - // Create background $bg = new ImagickPixel("rgb({$red},{$green},{$blue})"); - // Create a new canvas with the background color $canvas = new Imagick(); $canvas->newImage( $this->resource->getImageWidth(), @@ -238,7 +219,6 @@ protected function _flatten(int $red = 255, int $green = 255, int $blue = 255) $this->resource->getImageFormat(), ); - // Composite our image on the background $canvas->compositeImage( $this->resource, Imagick::COMPOSITE_OVER, @@ -246,7 +226,6 @@ protected function _flatten(int $red = 255, int $green = 255, int $blue = 255) 0, ); - // Replace our resource with the flattened version $this->resource->clear(); $this->resource = $canvas; @@ -393,13 +372,11 @@ protected function _text(string $text, array $options = []) $draw->setFillColor(new ImagickPixel("rgba({$r},{$g},{$b},{$opacity})")); } - // Calculate text positioning $imgWidth = $this->resource->getImageWidth(); $imgHeight = $this->resource->getImageHeight(); $xAxis = 0; $yAxis = 0; - // Default padding $padding = $options['padding'] ?? 0; if (isset($options['hAlign'])) { @@ -436,7 +413,7 @@ protected function _text(string $text, array $options = []) break; case 'bottom': - // Note: Vertical offset is inverted for bottom alignment as per original implementation + // A negative offset pushes the text further up from the bottom edge. $yAxis = $vOffset < 0 ? $imgHeight + $vOffset - $padding : $imgHeight - $vOffset - $padding; break; } @@ -470,7 +447,6 @@ protected function _text(string $text, array $options = []) ); } - // Draw the main text $this->resource->annotateImage( $draw, $xAxis, @@ -537,8 +513,6 @@ public function reorient(bool $silent = false) /** * Clears metadata from the image. * - * @return $this - * * @throws ImagickException */ public function clearMetadata(): static diff --git a/system/Images/Image.php b/system/Images/Image.php index 0634405c6ec0..e634b914016a 100644 --- a/system/Images/Image.php +++ b/system/Images/Image.php @@ -75,7 +75,7 @@ public function copy(string $targetPath, ?string $targetName = null, int $perms $targetName ??= $this->getFilename(); - if (empty($targetName)) { + if ($targetName === null || $targetName === '') { throw ImageException::forInvalidFile($targetName); } @@ -97,7 +97,7 @@ public function copy(string $targetPath, ?string $targetName = null, int $perms * * A helper function that gets info about the file * - * @return array|bool + * @return array{width: int, height: int, image_type: int, size_str: string, mime_type: string}|bool */ public function getProperties(bool $return = false) { diff --git a/system/Images/ImageHandlerInterface.php b/system/Images/ImageHandlerInterface.php index a96977214ec8..1bb1e2b20ee0 100644 --- a/system/Images/ImageHandlerInterface.php +++ b/system/Images/ImageHandlerInterface.php @@ -152,8 +152,6 @@ public function save(?string $target = null, int $quality = 90); /** * Clear metadata before saving image as a new file. - * - * @return $this */ public function clearMetadata(): static; } diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 01e60c402955..822186dba926 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -33,17 +33,19 @@ 'transformer' => 'Transformer class name', 'validation' => 'Validation class name', ], - 'commandType' => 'Command type', - 'databaseGroup' => 'Database group', - 'fileCreate' => 'File created: {0}', - 'fileError' => 'Error while creating file: "{0}"', - 'fileExist' => 'File exists: "{0}"', - 'fileOverwrite' => 'File overwritten: "{0}"', - 'parentClass' => 'Parent class', - 'returnType' => 'Return type', - 'tableName' => 'Table name', - 'usingCINamespace' => 'Warning: Using the "CodeIgniter" namespace will generate the file in the system directory.', - 'viewName' => [ + 'commandType' => 'Command type', + 'databaseGroup' => 'Database group', + 'fileCreate' => 'File created: {0}', + 'fileError' => 'Error while creating file: "{0}"', + 'fileExist' => 'File exists: "{0}"', + 'fileOverwrite' => 'File overwritten: "{0}"', + 'parentClass' => 'Parent class', + 'returnType' => 'Return type', + 'tableName' => 'Table name', + 'undefinedDatabaseGroup' => 'The "{0}" database group is not defined.', + 'unsupportedSessionDriver' => 'Database sessions are only supported on MySQLi and Postgre. The "{0}" database group uses the "{1}" driver.', + 'usingCINamespace' => 'Warning: Using the "CodeIgniter" namespace will generate the file in the system directory.', + 'viewName' => [ 'cell' => 'Cell view name', ], ], diff --git a/system/Language/en/Cookie.php b/system/Language/en/Cookie.php index c880f032935e..7fde16cd8434 100644 --- a/system/Language/en/Cookie.php +++ b/system/Language/en/Cookie.php @@ -16,6 +16,9 @@ 'invalidExpiresTime' => 'Invalid "{0}" type for "Expires" attribute. Expected: string, integer, DateTimeInterface object.', 'invalidExpiresValue' => 'The cookie expiration time is not valid.', 'invalidCookieName' => 'The cookie name "{0}" contains invalid characters.', + 'invalidCookieValue' => 'The cookie value contains invalid characters.', + 'invalidCookiePath' => 'The cookie path contains invalid characters.', + 'invalidCookieDomain' => 'The cookie domain contains invalid characters.', 'emptyCookieName' => 'The cookie name cannot be empty.', 'invalidSecurePrefix' => 'Using the "__Secure-" prefix requires setting the "Secure" attribute.', 'invalidHostPrefix' => 'Using the "__Host-" prefix must be set with the "Secure" flag, must not have a "Domain" attribute, and the "Path" is set to "/".', diff --git a/system/Language/en/View.php b/system/Language/en/View.php index 79d57f06b92e..d08c5f3038dc 100644 --- a/system/Language/en/View.php +++ b/system/Language/en/View.php @@ -20,4 +20,5 @@ 'invalidCellClass' => 'Unable to locate view cell class: "{0}".', 'tagSyntaxError' => 'You have a syntax error in your Parser tags: "{0}"', 'invalidDecoratorClass' => '"{0}" is not a valid View Decorator.', + 'restrictedConditional' => 'The Parser conditional is not allowed in restricted mode: "{0}"', ]; diff --git a/system/Log/Handlers/ChromeLoggerHandler.php b/system/Log/Handlers/ChromeLoggerHandler.php index 8d763399b513..ce8e97a12750 100644 --- a/system/Log/Handlers/ChromeLoggerHandler.php +++ b/system/Log/Handlers/ChromeLoggerHandler.php @@ -99,8 +99,8 @@ 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 object|string $message */ public function handle($level, $message): bool { diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 011920e15bd1..6b61837fa22b 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Log; +use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Log\Exceptions\LogException; use CodeIgniter\Log\Handlers\HandlerInterface; @@ -298,14 +299,14 @@ protected function interpolate($message, array $context = []) $replace = []; foreach ($context as $key => $val) { - // Verify that the 'exception' key is actually an exception - // or error, both of which implement the 'Throwable' interface. - if ($key === 'exception' && $val instanceof Throwable) { - $val = $val->getMessage() . ' ' . clean_path($val->getFile()) . ':' . $val->getLine(); + $placeholder = '{' . $key . '}'; + + if (! str_contains($message, $placeholder)) { + continue; } // todo - sanitize input before writing to file? - $replace['{' . $key . '}'] = $val; + $replace[$placeholder] = $this->stringifyContextValue($key, $val); } $replace['{post_vars}'] = '$_POST: ' . print_r(service('superglobals')->getPostArray(), true); @@ -337,6 +338,48 @@ protected function interpolate($message, array $context = []) return strtr($message, $replace); } + /** + * Converts context values to strings without raising PHP errors. + */ + protected function stringifyContextValue(int|string $key, mixed $value): string + { + // Verify that the 'exception' key is actually an exception + // or error, both of which implement the 'Throwable' interface. + if ($key === 'exception' && $value instanceof Throwable) { + return $value->getMessage() . ' ' . clean_path($value->getFile()) . ':' . $value->getLine(); + } + + if ($value === null || is_scalar($value)) { + return (string) $value; + } + + if ($value instanceof Stringable) { + try { + return (string) $value; + } catch (Throwable) { + return '[object ' . $value::class . ']'; + } + } + + if (is_array($value)) { + return print_r($value, true); + } + + if ($value instanceof Entity) { + try { + return print_r($value->toArray(), true); + } catch (Throwable) { + return '[object ' . $value::class . ']'; + } + } + + if (is_object($value)) { + return '[object ' . $value::class . ']'; + } + + return '[' . get_debug_type($value) . ']'; + } + /** * Determines the file and line that the logging call * was made from by analyzing the backtrace. diff --git a/system/Model.php b/system/Model.php index 0d3c55ee0568..c73cdba448c7 100644 --- a/system/Model.php +++ b/system/Model.php @@ -40,6 +40,11 @@ * * @property-read BaseConnection $db * + * @phpstan-type WhenCallback callable(BaseBuilder, mixed): mixed + * @phpstan-type WhenDefaultCallback callable(BaseBuilder): mixed + * + * @phpstan-import-type row_array from BaseModel + * * @method $this groupBy($by, ?bool $escape = null) * @method $this groupEnd() * @method $this groupStart() @@ -78,15 +83,11 @@ * @method $this selectMax(string $select = '', string $alias = '') * @method $this selectMin(string $select = '', string $alias = '') * @method $this selectSum(string $select = '', string $alias = '') - * @method $this when($condition, callable $callback, ?callable $defaultCallback = null) - * @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null) + * @method $this when($condition, WhenCallback $callback, ?WhenDefaultCallback $defaultCallback = null) + * @method $this whenNot($condition, WhenCallback $callback, ?WhenDefaultCallback $defaultCallback = null) * @method $this where($key, $value = null, ?bool $escape = null) * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null) * @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) - * @phpstan-method $this whenNot($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null) - * @phpstan-import-type row_array from BaseModel */ class Model extends BaseModel { @@ -123,7 +124,7 @@ class Model extends BaseModel * so that we can capture it (not the builder) * and ensure it gets validated first. * - * @var array{escape: array, data: row_array}|array{} + * @var array{escape: array, data: row_array}|array{} */ protected $tempData = []; @@ -131,7 +132,7 @@ class Model extends BaseModel * Escape array that maps usage of escape * flag for every parameter. * - * @var array + * @var array */ protected $escape = []; diff --git a/system/Modules/Modules.php b/system/Modules/Modules.php index ca4fe40ab128..4a7ce3ef942c 100644 --- a/system/Modules/Modules.php +++ b/system/Modules/Modules.php @@ -60,6 +60,9 @@ public function shouldDiscover(string $alias): bool return in_array(strtolower($alias), $this->aliases, true); } + /** + * @param array $array + */ public static function __set_state(array $array) { $obj = new static(); diff --git a/system/Pager/Pager.php b/system/Pager/Pager.php index a4883f3809ae..4537f1d43db3 100644 --- a/system/Pager/Pager.php +++ b/system/Pager/Pager.php @@ -34,14 +34,23 @@ class Pager implements PagerInterface /** * The group data. * - * @var array + * @var array */ protected $groups = []; /** * URI segment for groups if provided. * - * @var array + * @var array */ protected $segment = []; @@ -216,7 +225,9 @@ public function getCurrentPage(string $group = 'default'): int { $this->ensureGroup($group); - return $this->groups[$group]['currentPage'] ?: 1; + $currentPage = $this->groups[$group]['currentPage']; + + return ($currentPage === 0) ? 1 : $currentPage; } /** @@ -270,7 +281,7 @@ public function getPageURI(?int $page = null, string $group = 'default', bool $r $segment = $this->segment[$group] ?? 0; - if ($segment) { + if ($segment !== 0) { $uri->setSegment($segment, $page); } else { $uri->addQuery($this->groups[$group]['pageSelector'], $page); @@ -279,7 +290,7 @@ public function getPageURI(?int $page = null, string $group = 'default', bool $r if ($this->only !== null) { $query = array_intersect_key(service('superglobals')->getGetArray(), array_flip($this->only)); - if (! $segment) { + if ($segment === 0) { $query[$this->groups[$group]['pageSelector']] = $page; } @@ -310,7 +321,7 @@ public function getNextPageURI(string $group = 'default', bool $returnObject = f $curr = $this->getCurrentPage($group); $page = null; - if (! empty($last) && $curr !== 0 && $last === $curr) { + if (! in_array($last, [null, 0], true) && $curr !== 0 && $last === $curr) { return null; } @@ -355,12 +366,6 @@ public function getPerPage(string $group = 'default'): int return (int) $this->groups[$group]['perPage']; } - /** - * Returns an array with details about the results, including - * total, per_page, current_page, last_page, next_url, prev_url, from, to. - * Does not include the actual data. This data is suitable for adding - * a 'data' object to with the result set and converting to JSON. - */ public function getDetails(string $group = 'default'): array { if (! array_key_exists($group, $this->groups)) { @@ -378,6 +383,8 @@ public function getDetails(string $group = 'default'): array /** * Sets only allowed queries on pagination links. + * + * @param list $queries */ public function only(array $queries): self { @@ -434,7 +441,7 @@ protected function calculateCurrentPage(string $group) $page = (int) (service('superglobals')->get($pageSelector, '1')); - $this->groups[$group]['currentPage'] = $page < 1 ? 1 : $page; + $this->groups[$group]['currentPage'] = max(1, $page); } } } diff --git a/system/Pager/PagerInterface.php b/system/Pager/PagerInterface.php index 650b04f48e30..bfffd92411a8 100644 --- a/system/Pager/PagerInterface.php +++ b/system/Pager/PagerInterface.php @@ -115,6 +115,20 @@ public function getPerPage(string $group = 'default'): int; * total, per_page, current_page, last_page, next_url, prev_url, from, to. * Does not include the actual data. This data is suitable for adding * a 'data' object to with the result set and converting to JSON. + * + * @return array{ + * currentUri: URI, + * uri: URI, + * hasMore: bool, + * total: int|null, + * perPage: int, + * pageCount: int, + * pageSelector: string, + * currentPage: int, + * next: string|null, + * previous: string|null, + * segment: int + * } */ public function getDetails(string $group = 'default'): array; } diff --git a/system/Pager/PagerRenderer.php b/system/Pager/PagerRenderer.php index f970ee551f19..70959c820ea4 100644 --- a/system/Pager/PagerRenderer.php +++ b/system/Pager/PagerRenderer.php @@ -101,7 +101,16 @@ class PagerRenderer protected ?int $perPageEnd = null; /** - * Constructor. + * @param array{ + * uri: URI, + * pageCount: int, + * currentPage: int, + * total: int|null, + * segment?: int, + * pageSelector?: string, + * perPage?: int|null, + * ... + * } $details */ public function __construct(array $details) { diff --git a/system/Publisher/ContentReplacer.php b/system/Publisher/ContentReplacer.php index 6e8c01fa2e41..b263b1a18d7f 100644 --- a/system/Publisher/ContentReplacer.php +++ b/system/Publisher/ContentReplacer.php @@ -25,7 +25,7 @@ class ContentReplacer /** * Replace content * - * @param array $replaces [search => replace] + * @param array $replaces [search => replace] */ public function replace(string $content, array $replaces): string { diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 8d9f6b72327a..abf414f45c37 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -413,7 +413,7 @@ final public function merge(bool $replace = true): bool /** * Replace content * - * @param array $replaces [search => replace] + * @param array $replaces [search => replace] */ public function replace(string $file, array $replaces): bool { diff --git a/system/RESTful/BaseResource.php b/system/RESTful/BaseResource.php index f2e171c8319e..e5ff13e1abdb 100644 --- a/system/RESTful/BaseResource.php +++ b/system/RESTful/BaseResource.php @@ -42,6 +42,8 @@ abstract class BaseResource extends Controller /** * Constructor. * + * @param CLIRequest|IncomingRequest $request + * * @return void */ public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger) @@ -66,11 +68,11 @@ public function setModel($which = null) $this->modelName = is_object($which) ? null : $which; } - if (empty($this->model) && ! empty($this->modelName) && class_exists($this->modelName)) { + if ($this->model === null && $this->modelName !== null && class_exists($this->modelName)) { $this->model = model($this->modelName); } - if (! empty($this->model) && empty($this->modelName)) { + if ($this->model !== null && ($this->modelName === null || $this->modelName === '')) { $this->modelName = $this->model::class; } } diff --git a/system/Router/Attributes/Filter.php b/system/Router/Attributes/Filter.php index 130d2946f0dc..edba9452619e 100644 --- a/system/Router/Attributes/Filter.php +++ b/system/Router/Attributes/Filter.php @@ -38,6 +38,9 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Filter implements RouteAttributeInterface { + /** + * @param array $having + */ public function __construct( public string $by, public array $having = [], @@ -56,6 +59,9 @@ public function after(RequestInterface $request, ResponseInterface $response): ? return null; } + /** + * @return list + */ public function getFilters(): array { if ($this->having === []) { diff --git a/system/Router/Attributes/Restrict.php b/system/Router/Attributes/Restrict.php index 79344a7c6982..3bf250ab7966 100644 --- a/system/Router/Attributes/Restrict.php +++ b/system/Router/Attributes/Restrict.php @@ -42,6 +42,11 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Restrict implements RouteAttributeInterface { + /** + * @param list|string|null $environment + * @param list|string|null $hostname + * @param list|string|null $subdomain + */ public function __construct( public array|string|null $environment = null, public array|string|null $hostname = null, diff --git a/system/Router/AutoRouter.php b/system/Router/AutoRouter.php index 3a81f3a7bc6b..e887469b9037 100644 --- a/system/Router/AutoRouter.php +++ b/system/Router/AutoRouter.php @@ -55,14 +55,6 @@ public function __construct( ) { } - /** - * Attempts to match a URI path against Controllers and directories - * found in APPPATH/Controllers, to find a matching route. - * - * @param string $httpVerb HTTP verb like `GET`,`POST` - * - * @return array [directory_name, controller_name, controller_method, params] - */ public function getRoute(string $uri, string $httpVerb): array { $segments = explode('/', $uri); @@ -86,7 +78,8 @@ public function getRoute(string $uri, string $httpVerb): array // If it doesn't, no biggie - the default method name // has already been set. if ($segments !== []) { - $this->method = array_shift($segments) ?: $this->method; + $method = array_shift($segments); + $this->method = $method === '' ? $this->method : $method; } // Prevent access to initController method @@ -94,7 +87,7 @@ public function getRoute(string $uri, string $httpVerb): array throw PageNotFoundException::forPageNotFound(); } - /** @var array $params An array of params to the controller method. */ + /** @var list $params An array of params to the controller method. */ $params = []; if ($segments !== []) { @@ -176,11 +169,12 @@ public function setTranslateURIDashes(bool $val = false): self } /** - * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments + * Scans the controller directory, attempting to locate a controller matching the supplied uri `$segments`. + * Returns an array of remaining uri segments that don't map onto a directory. * - * @param array $segments URI segments + * @param list $segments URI segments * - * @return array returns an array of remaining uri segments that don't map onto a directory + * @return list */ private function scanControllers(array $segments): array { diff --git a/system/Router/AutoRouterImproved.php b/system/Router/AutoRouterImproved.php index a00c04d265d7..c7dbf04efec5 100644 --- a/system/Router/AutoRouterImproved.php +++ b/system/Router/AutoRouterImproved.php @@ -63,12 +63,11 @@ final class AutoRouterImproved implements AutoRouterInterface * Map of URI segments and namespaces. * * The key is the first URI segment. The value is the controller namespace. - * E.g., - * [ - * 'blog' => 'Acme\Blog\Controllers', - * ] + * ``` + * ['blog' => 'Acme\Blog\Controllers'] + * ``` * - * @var array [ uri_segment => namespace ] + * @var array */ private array $moduleRoutes; @@ -133,6 +132,9 @@ public function __construct( $this->controller = $this->defaultController; } + /** + * @return list + */ private function createSegments(string $uri): array { $segments = explode('/', $uri); @@ -250,13 +252,6 @@ private function searchLastDefaultController(): bool return false; } - /** - * Finds controller, method and params from the URI. - * - * @param string $httpVerb HTTP verb like `GET`,`POST` - * - * @return array [directory_name, controller_name, controller_method, params] - */ public function getRoute(string $uri, string $httpVerb): array { $this->uri = $uri; diff --git a/system/Router/AutoRouterInterface.php b/system/Router/AutoRouterInterface.php index 7edec821dbfd..58f22393881e 100644 --- a/system/Router/AutoRouterInterface.php +++ b/system/Router/AutoRouterInterface.php @@ -19,9 +19,11 @@ interface AutoRouterInterface { /** - * Returns controller, method and params from the URI. + * Returns the directory name, controller name, controller method, and any parameters for the given URI and HTTP verb. * - * @return array [directory_name, controller_name, controller_method, params] + * @param string $httpVerb HTTP verb like `GET`,`POST` + * + * @return array{string|null, string, string, list} */ public function getRoute(string $uri, string $httpVerb): array; } diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 3c08958604d2..c08c01530b79 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -96,6 +96,8 @@ class RouteCollection implements RouteCollectionInterface /** * An array of files that would contain route definitions. + * + * @var list */ protected array $routeFiles = []; @@ -116,9 +118,7 @@ class RouteCollection implements RouteCollectionInterface /** * An array of all routes and their mappings. - * - * @var array - * + *``` * [ * verb => [ * routeKey(regex) => [ @@ -137,6 +137,9 @@ class RouteCollection implements RouteCollectionInterface * ], * ], * ] + * ``` + * + * @var array>> */ protected $routes = [ '*' => [], @@ -154,14 +157,11 @@ class RouteCollection implements RouteCollectionInterface /** * Array of routes names + * ``` + * [verb => [routeName => routeKey(regex)]] + * ``` * - * @var array - * - * [ - * verb => [ - * routeName => routeKey(regex) - * ], - * ] + * @var array> */ protected $routesNames = [ '*' => [], @@ -179,9 +179,7 @@ class RouteCollection implements RouteCollectionInterface /** * Array of routes options - * - * @var array - * + * ``` * [ * verb => [ * routeKey(regex) => [ @@ -189,6 +187,9 @@ class RouteCollection implements RouteCollectionInterface * ] * ], * ] + * ``` + * + * @var array>> */ protected $routesOptions = []; @@ -225,7 +226,7 @@ class RouteCollection implements RouteCollectionInterface * Stores copy of current options being * applied during creation. * - * @var array|null + * @var array|null */ protected $currentOptions; @@ -376,16 +377,6 @@ protected function discoverRoutes() $this->didDiscover = true; } - /** - * Registers a new constraint with the system. Constraints are used - * by the routes as placeholders for regular expressions to make defining - * the routes more human-friendly. - * - * You can pass an associative array as $placeholder, and have - * multiple placeholders added at once. - * - * @param array|string $placeholder - */ public function addPlaceholder($placeholder, ?string $pattern = null): RouteCollectionInterface { if (! is_array($placeholder)) { @@ -409,10 +400,6 @@ public function getPlaceholders(): array return $this->placeholders; } - /** - * Sets the default namespace to use for Controllers when no other - * namespace has been specified. - */ public function setDefaultNamespace(string $value): RouteCollectionInterface { $this->defaultNamespace = esc(strip_tags($value)); @@ -421,10 +408,6 @@ public function setDefaultNamespace(string $value): RouteCollectionInterface return $this; } - /** - * Sets the default controller to use when no other controller has been - * specified. - */ public function setDefaultController(string $value): RouteCollectionInterface { $this->defaultController = esc(strip_tags($value)); @@ -432,10 +415,6 @@ public function setDefaultController(string $value): RouteCollectionInterface return $this; } - /** - * Sets the default method to call on the controller when no other - * method has been set in the route. - */ public function setDefaultMethod(string $value): RouteCollectionInterface { $this->defaultMethod = esc(strip_tags($value)); @@ -443,13 +422,6 @@ public function setDefaultMethod(string $value): RouteCollectionInterface return $this; } - /** - * Tells the system whether to convert dashes in URI strings into - * underscores. In some search engines, including Google, dashes - * create more meaning and make it easier for the search engine to - * find words and meaning in the URI for better SEO. But it - * doesn't work well with PHP method names.... - */ public function setTranslateURIDashes(bool $value): RouteCollectionInterface { $this->translateURIDashes = $value; @@ -457,14 +429,6 @@ public function setTranslateURIDashes(bool $value): RouteCollectionInterface return $this; } - /** - * If TRUE, the system will attempt to match the URI against - * Controllers by matching each segment against folders/files - * in APPPATH/Controllers, when a match wasn't found against - * defined routes. - * - * If FALSE, will stop searching and do NO automatic routing. - */ public function setAutoRoute(bool $value): RouteCollectionInterface { $this->autoRoute = $value; @@ -472,15 +436,6 @@ public function setAutoRoute(bool $value): RouteCollectionInterface return $this; } - /** - * Sets the class/method that should be called if routing doesn't - * find a match. It can be either a closure or the controller/method - * name exactly like a route is defined: Users::index - * - * This setting is passed to the Router class and handled there. - * - * @param (callable(string): (ResponseInterface|string|void))|string|null $callable - */ public function set404Override($callable = null): RouteCollectionInterface { $this->override404 = $callable; @@ -488,12 +443,6 @@ public function set404Override($callable = null): RouteCollectionInterface return $this; } - /** - * Returns the 404 Override setting, which can be null, a closure - * or the controller/string. - * - * @return (Closure(string): (ResponseInterface|string|void))|string|null - */ public function get404Override() { return $this->override404; @@ -512,52 +461,31 @@ public function setDefaultConstraint(string $placeholder): RouteCollectionInterf return $this; } - /** - * Returns the name of the default controller. With Namespace. - */ public function getDefaultController(): string { return $this->defaultController; } - /** - * Returns the name of the default method to use within the controller. - */ public function getDefaultMethod(): string { return $this->defaultMethod; } - /** - * Returns the default namespace as set in the Routes config file. - */ public function getDefaultNamespace(): string { return $this->defaultNamespace; } - /** - * Returns the current value of the translateURIDashes setting. - */ public function shouldTranslateURIDashes(): bool { return $this->translateURIDashes; } - /** - * Returns the flag that tells whether to autoRoute URI against Controllers. - */ public function shouldAutoRoute(): bool { return $this->autoRoute; } - /** - * Returns the raw array of available routes. - * - * @param non-empty-string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`. - * @param bool $includeWildcard Whether to include '*' routes. - */ public function getRoutes(?string $verb = null, bool $includeWildcard = true): array { if ((string) $verb === '') { @@ -598,13 +526,6 @@ public function getRoutes(?string $verb = null, bool $includeWildcard = true): a return $routes; } - /** - * Returns one or all routes options - * - * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`. - * - * @return array [key => value] - */ public function getRoutesOptions(?string $from = null, ?string $verb = null): array { $options = $this->loadRoutesOptions($verb); @@ -612,22 +533,11 @@ public function getRoutesOptions(?string $from = null, ?string $verb = null): ar return ((string) $from !== '') ? $options[$from] ?? [] : $options; } - /** - * Returns the current HTTP Verb being used. - */ public function getHTTPVerb(): string { return $this->HTTPVerb; } - /** - * Sets the current HTTP verb. - * Used primarily for testing. - * - * @param string $verb HTTP verb - * - * @return $this - */ public function setHTTPVerb(string $verb) { if ($verb !== '*' && $verb === strtolower($verb)) { @@ -651,6 +561,9 @@ public function setHTTPVerb(string $verb) * A shortcut method to add a number of routes at a single time. * It does not allow any options to be set on the route, or to * define the method used. + * + * @param array $routes + * @param array|null $options */ public function map(array $routes = [], ?array $options = null): RouteCollectionInterface { @@ -661,14 +574,6 @@ public function map(array $routes = [], ?array $options = null): RouteCollection return $this; } - /** - * Adds a single route to the collection. - * - * Example: - * $routes->add('news', 'Posts::index'); - * - * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to - */ public function add(string $from, $to, ?array $options = null): RouteCollectionInterface { $this->create('*', $from, $to, $options); @@ -708,11 +613,6 @@ public function addRedirect(string $from, string $to, int $status = 302) return $this; } - /** - * Determines if the route is a redirecting route. - * - * @param string $routeKey routeKey or route name - */ public function isRedirect(string $routeKey): bool { if (isset($this->routes['*'][$routeKey]['redirect'])) { @@ -730,11 +630,6 @@ public function isRedirect(string $routeKey): bool return false; } - /** - * Grabs the HTTP status code from a redirecting Route. - * - * @param string $routeKey routeKey or route name - */ public function getRedirectCode(string $routeKey): int { if (isset($this->routes['*'][$routeKey]['redirect'])) { @@ -762,8 +657,8 @@ public function getRedirectCode(string $routeKey): int * $route->resource('users'); * }); * - * @param string $name The name to group/prefix the routes with. - * @param array|(callable(self): void) ...$params + * @param string $name The name to group/prefix the routes with. + * @param array|(callable(self): void) ...$params * * @return void */ @@ -844,8 +739,8 @@ public function group(string $name, ...$params) * POST /photos/{id}/delete delete * POST /photos/{id} update * - * @param string $name The name of the resource/controller to route to. - * @param array|null $options A list of possible ways to customize the routing. + * @param string $name The name of the resource/controller to route to. + * @param array|null $options A list of possible ways to customize the routing. */ public function resource(string $name, ?array $options = null): RouteCollectionInterface { @@ -938,8 +833,8 @@ public function resource(string $name, ?array $options = null): RouteCollectionI * GET /photos/remove/{id} remove show a form to confirm deletion of a specific photo object * POST /photos/delete/{id} delete deleting the specified photo object * - * @param string $name The name of the controller to route to. - * @param array|null $options A list of possible ways to customize the routing. + * @param string $name The name of the controller to route to. + * @param array|null $options A list of possible ways to customize the routing. */ public function presenter(string $name, ?array $options = null): RouteCollectionInterface { @@ -1013,11 +908,13 @@ 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 list $verbs + * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface { - if ($from === '' || empty($to)) { + if ($from === '' || in_array($to, ['', []], true)) { throw new InvalidArgumentException('You must supply the parameters: from, to.'); } @@ -1045,7 +942,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function get(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1057,7 +955,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function post(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1069,7 +968,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function put(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1081,7 +981,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1093,7 +994,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function head(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1105,7 +1007,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1117,7 +1020,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function options(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1129,7 +1033,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options */ public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface { @@ -1141,6 +1046,8 @@ public function cli(string $from, $to, ?array $options = null): RouteCollectionI /** * Specifies a route that will only display a view. * Only works for GET requests. + * + * @param array|null $options */ public function view(string $from, string $view, ?array $options = null): RouteCollectionInterface { @@ -1170,25 +1077,6 @@ public function environment(string $env, Closure $callback): RouteCollectionInte return $this; } - /** - * Attempts to look up a route based on its destination. - * - * If a route exists: - * - * 'path/(:any)/(:any)' => 'Controller::method/$1/$2' - * - * This method allows you to know the Controller and method - * and get the route that leads to it. - * - * // Equals 'path/$param1/$param2' - * reverseRoute('Controller::method', $param1, $param2); - * - * @param string $search Route name or Controller::method - * @param int|string ...$params One or more parameters to be passed to the route. - * The last parameter allows you to set the locale. - * - * @return false|string The route (URI path relative to baseURL) or false if not found. - */ public function reverseRoute(string $search, ...$params) { if ($search === '') { @@ -1262,11 +1150,6 @@ protected function localizeRoute(string $route): string return strtr($route, ['{locale}' => service('request')->getLocale()]); } - /** - * Checks a route (using the "from") to see if it's filtered or not. - * - * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`. - */ public function isFiltered(string $search, ?string $verb = null): bool { $options = $this->loadRoutesOptions($verb); @@ -1274,21 +1157,6 @@ public function isFiltered(string $search, ?string $verb = null): bool return isset($options[$search]['filter']); } - /** - * Returns the filters that should be applied for a single route, along - * with any parameters it might have. Parameters are found by splitting - * the parameter name on a colon to separate the filter name from the parameter list, - * and the splitting the result on commas. So: - * - * 'role:admin,manager' - * - * has a filter of "role", with parameters of ['admin', 'manager']. - * - * @param string $search routeKey - * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`. - * - * @return list filter_name or filter_name:arguments like 'role:admin,manager' - */ public function getFiltersForRoute(string $search, ?string $verb = null): array { $options = $this->loadRoutesOptions($verb); @@ -1307,6 +1175,8 @@ public function getFiltersForRoute(string $search, ?string $verb = null): array /** * Given a * + * @param list|null $params + * * @throws RouterException * * @deprecated Unused. Now uses buildReverseRoute(). @@ -1316,7 +1186,7 @@ protected function fillRouteParams(string $from, ?array $params = null): string // Find all of our back-references in the original route preg_match_all('/\(([^)]+)\)/', $from, $matches); - if (empty($matches[0])) { + if ($matches[0] === []) { return '/' . ltrim($from, '/'); } @@ -1335,7 +1205,7 @@ protected function fillRouteParams(string $from, ?array $params = null): string // Ensure that the param we're inserting matches // the expected param type. - $pos = strpos($from, $pattern); + $pos = strpos($from, (string) $pattern); $from = substr_replace($from, $params[$index], $pos, strlen($pattern)); } @@ -1345,8 +1215,8 @@ protected function fillRouteParams(string $from, ?array $params = null): string /** * Builds reverse route * - * @param array $params One or more parameters to be passed to the route. - * The last parameter allows you to set the locale. + * @param list $params One or more parameters to be passed to the route. + * The last parameter allows you to set the locale. */ protected function buildReverseRoute(string $from, array $params): string { @@ -1355,7 +1225,7 @@ protected function buildReverseRoute(string $from, array $params): string // Find all of our back-references in the original route preg_match_all('/\(([^)]+)\)/', $from, $matches); - if (empty($matches[0])) { + if ($matches[0] === []) { if (str_contains($from, '{locale}')) { $locale = $params[0] ?? null; } @@ -1397,7 +1267,7 @@ protected function buildReverseRoute(string $from, array $params): string // Ensure that the param we're inserting matches // the expected param type. - $pos = strpos($from, $placeholder); + $pos = strpos($from, (string) $placeholder); $from = substr_replace($from, (string) $params[$index], $pos, strlen($placeholder)); } @@ -1435,7 +1305,8 @@ 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(mixed...): (ResponseInterface|string|void))|string $to + * @param array|null $options * * @return void */ @@ -1475,7 +1346,7 @@ protected function create(string $verb, string $from, $to, ?array $options = nul } // Hostname limiting? - if (! empty($options['hostname'])) { + if (! in_array($options['hostname'] ?? '', ['', '0', []], true)) { // @todo determine if there's a way to whitelist hosts? if (! $this->checkHostname($options['hostname'])) { return; @@ -1484,7 +1355,7 @@ protected function create(string $verb, string $from, $to, ?array $options = nul $overwrite = true; } // Limiting to subdomains? - elseif (! empty($options['subdomain'])) { + elseif (! in_array($options['subdomain'] ?? '', ['', '0', []], true)) { // If we don't match the current subdomain, then // we don't need to add the route. if (! $this->checkSubdomains($options['subdomain'])) { @@ -1582,7 +1453,12 @@ private function checkHostname($hostname): bool return strtolower($this->httpHost) === strtolower($hostname); } - private function processArrayCallableSyntax(string $from, array $to): string + /** + * @param array $to + * + * @return array|string + */ + private function processArrayCallableSyntax(string $from, array $to): array|string { // [classname, method] // eg, [Home::class, 'index'] @@ -1600,7 +1476,7 @@ private function processArrayCallableSyntax(string $from, array $to): string && is_callable($to[0], true, $callableName) && is_string($to[1]) ) { - $to = '\\' . $callableName . '/' . $to[1]; + return '\\' . $callableName . '/' . $to[1]; } return $to; @@ -1636,9 +1512,7 @@ private function checkSubdomains($subdomains): bool return false; } - if ($this->currentSubdomain === null) { - $this->currentSubdomain = parse_subdomain($this->httpHost); - } + $this->currentSubdomain ??= parse_subdomain($this->httpHost); if (! is_array($subdomains)) { $subdomains = [$subdomains]; @@ -1646,7 +1520,7 @@ private function checkSubdomains($subdomains): bool // Routes can be limited to any sub-domain. In that case, though, // it does require a sub-domain to be present. - if (! empty($this->currentSubdomain) && in_array('*', $subdomains, true)) { + if ($this->currentSubdomain !== '' && in_array('*', $subdomains, true)) { return true; } @@ -1797,9 +1671,6 @@ public function useSupportedLocalesOnly(bool $useOnly): self return $this; } - /** - * Get the flag that limit or not the routes with {locale} placeholder to App::$supportedLocales - */ public function shouldUseSupportedLocalesOnly(): bool { return $this->useSupportedLocalesOnly; diff --git a/system/Router/RouteCollectionInterface.php b/system/Router/RouteCollectionInterface.php index 10587d81ee4c..e3c83955e28f 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 + * Example: + * $routes->add('news', 'Posts::index'); + * + * @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 * * @return RouteCollectionInterface */ @@ -44,8 +47,8 @@ public function add(string $from, $to, ?array $options = null); * You can pass an associative array as $placeholder, and have * multiple placeholders added at once. * - * @param array|string $placeholder - * @param string|null $pattern The regex pattern + * @param array|string $placeholder + * @param string|null $pattern The regex pattern * * @return RouteCollectionInterface */ @@ -177,6 +180,8 @@ public function shouldAutoRoute(); * * @param non-empty-string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`. * @param bool $includeWildcard Whether to include '*' routes. + * + * @return array|(Closure(mixed...): (ResponseInterface|string|void))|string> */ public function getRoutes(?string $verb = null, bool $includeWildcard = true): array; @@ -192,6 +197,7 @@ public function getRoutesOptions(?string $from = null, ?string $verb = null): ar /** * Sets the current HTTP verb. + * Used primarily for testing. * * @param string $verb HTTP verb * @@ -219,8 +225,9 @@ public function getHTTPVerb(); * // Equals 'path/$param1/$param2' * reverseRoute('Controller::method', $param1, $param2); * - * @param string $search Named route or Controller::method - * @param int|string ...$params + * @param string $search Route name or Controller::method + * @param int|string ...$params One or more parameters to be passed to the route. + * The last parameter allows you to set the locale. * * @return false|string The route (URI path relative to baseURL) or false if not found. */ diff --git a/system/Router/Router.php b/system/Router/Router.php index 063bb5074cbb..6e5a4630832b 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -86,7 +86,7 @@ class Router implements RouterInterface * An array of binds that were collected * so they can be sent to closure routes. * - * @var array + * @var list */ protected $params = []; @@ -108,14 +108,14 @@ class Router implements RouterInterface /** * The route that was matched for this request. * - * @var array|null + * @var array{string, (callable(mixed...): (ResponseInterface|string|void))|string}|null */ protected $matchedRoute; /** * The options set for the matched route. * - * @var array|null + * @var array|null */ protected $matchedRouteOptions; @@ -346,7 +346,7 @@ public function directory(): string * Returns the routing information that was matched for this * request, if a route was defined. * - * @return array|null + * @return array{string, (callable(mixed...): (ResponseInterface|string|void))|string}|null */ public function getMatchedRoute() { @@ -356,7 +356,7 @@ public function getMatchedRoute() /** * Returns all options set for the matched route * - * @return array|null + * @return array|null */ public function getMatchedRouteOptions() { @@ -562,6 +562,8 @@ protected function checkRoutes(string $uri): bool /** * Replace string `$n` with `$matches[n]` value. + * + * @param list $matches */ private function replaceBackReferences(string $input, array $matches): string { @@ -595,9 +597,9 @@ public function autoRoute(string $uri) /** * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments * - * @param array $segments URI segments + * @param list $segments URI segments * - * @return array returns an array of remaining uri segments that don't map onto a directory + * @return list 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 * @@ -611,9 +613,9 @@ protected function validateRequest(array $segments): array /** * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments * - * @param array $segments URI segments + * @param list $segments URI segments * - * @return array returns an array of remaining uri segments that don't map onto a directory + * @return list Returns an array of remaining uri segments that don't map onto a directory * * @deprecated Not used. Moved to AutoRouter class. */ @@ -694,7 +696,7 @@ private function isValidSegment(string $segment): bool * Takes an array of URI segments as input and sets the class/method * to be called. * - * @param array $segments URI segments + * @param list $segments URI segments * * @return void */ @@ -705,13 +707,13 @@ protected function setRequest(array $segments = []) return; } - [$controller, $method] = array_pad(explode('::', $segments[0]), 2, null); + [$controller, $method] = explode('::', $segments[0], 2) + [null, null]; $this->controller = $controller; // $this->method already contains the default method name, // so don't overwrite it with emptiness. - if (! empty($method)) { + if ($method !== null && $method !== '') { $this->method = $method; } @@ -729,7 +731,7 @@ protected function setRequest(array $segments = []) */ protected function setDefaultController() { - if (empty($this->controller)) { + if (! is_string($this->controller) || $this->controller === '') { throw RouterException::forMissingDefaultRoute(); } @@ -914,7 +916,7 @@ public function executeAfterAttributes(RequestInterface $request, ResponseInterf * Returns the route attributes collected during routing * for the current route. * - * @return array{class: list, method: list} + * @return array{class: list, method: list} */ public function getRouteAttributes(): array { diff --git a/system/Router/RouterInterface.php b/system/Router/RouterInterface.php index c50de86d43df..05dd2caf279e 100644 --- a/system/Router/RouterInterface.php +++ b/system/Router/RouterInterface.php @@ -55,7 +55,7 @@ public function methodName(); * during the parsing process as an array, ready to send to * instance->method(...$params). * - * @return array + * @return list */ public function params(); diff --git a/system/Session/Handlers/DatabaseHandler.php b/system/Session/Handlers/DatabaseHandler.php index c4ce2b8222e7..433e85ba3cdc 100644 --- a/system/Session/Handlers/DatabaseHandler.php +++ b/system/Session/Handlers/DatabaseHandler.php @@ -115,9 +115,7 @@ public function read($id): false|string return ''; } - if (! isset($this->sessionID)) { - $this->sessionID = $id; - } + $this->sessionID ??= $id; $builder = $this->db->table($this->table)->where('id', $this->idPrefix . $id); diff --git a/system/Session/Handlers/FileHandler.php b/system/Session/Handlers/FileHandler.php index f5e9261af0f4..b01b23ce03a9 100644 --- a/system/Session/Handlers/FileHandler.php +++ b/system/Session/Handlers/FileHandler.php @@ -24,8 +24,6 @@ class FileHandler extends BaseHandler { /** * Where to save the session files to. - * - * @var string */ protected $savePath; @@ -68,7 +66,7 @@ public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); - if ($this->savePath !== '') { + if (is_string($this->savePath) && $this->savePath !== '') { $this->savePath = rtrim($this->savePath, '/\\'); ini_set('session.save_path', $this->savePath); } else { @@ -136,9 +134,7 @@ public function read($id): false|string return false; } - if (! isset($this->sessionID)) { - $this->sessionID = $id; - } + $this->sessionID ??= $id; if ($this->fileNew) { chmod($this->filePath . $id, 0600); @@ -302,9 +298,7 @@ public function gc($max_lifetime): false|int } /** - * Configure Session ID regular expression. - * - * To make life easier, we force the PHP defaults. Because PHP9 forces them. + * Configure Session ID regular expression. To make life easier, we force the PHP defaults because PHP 9 forces them. * * @see https://wiki.php.net/rfc/deprecations_php_8_4#sessionsid_length_and_sessionsid_bits_per_character * @@ -312,17 +306,12 @@ public function gc($max_lifetime): false|int */ protected function configureSessionIDRegex() { - $bitsPerCharacter = (int) ini_get('session.sid_bits_per_character'); - $sidLength = (int) ini_get('session.sid_length'); + if ((int) ini_get('session.sid_bits_per_character') !== 4) { + ini_set('session.sid_bits_per_character', '4'); + } - // We force the PHP defaults. - if (PHP_VERSION_ID < 90000) { - if ($bitsPerCharacter !== 4) { - ini_set('session.sid_bits_per_character', '4'); - } - if ($sidLength !== 32) { - ini_set('session.sid_length', '32'); - } + if ((int) ini_get('session.sid_length') !== 32) { + ini_set('session.sid_length', '32'); } $this->sessionIDRegex = '[0-9a-f]{32}'; diff --git a/system/Session/Handlers/MemcachedHandler.php b/system/Session/Handlers/MemcachedHandler.php index 7b7775916019..c64ba65f6454 100644 --- a/system/Session/Handlers/MemcachedHandler.php +++ b/system/Session/Handlers/MemcachedHandler.php @@ -163,9 +163,7 @@ public function open($path, $name): bool public function read($id): false|string { if (isset($this->memcached) && $this->lockSession($id)) { - if (! isset($this->sessionID)) { - $this->sessionID = $id; - } + $this->sessionID ??= $id; $data = (string) $this->memcached->get($this->keyPrefix . $id); diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 54f628c4789b..0ce94ce80791 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -174,7 +174,7 @@ protected function setSavePath(): void */ public function open($path, $name): bool { - if (empty($this->savePath)) { + if ($this->savePath === [] || $this->savePath === '') { return false; } @@ -228,9 +228,7 @@ public function open($path, $name): bool public function read($id): false|string { if (isset($this->redis) && $this->lockSession($id)) { - if (! isset($this->sessionID)) { - $this->sessionID = $id; - } + $this->sessionID ??= $id; $data = $this->redis->get($this->keyPrefix . $id); @@ -275,7 +273,7 @@ public function write($id, $data): bool $this->redis->expire($this->lockKey, 300); if ($this->fingerprint !== ($fingerprint = md5($data)) || $this->keyExists === false) { - if ($this->redis->set($this->keyPrefix . $id, $data, $this->sessionExpiration)) { + if ($this->redis->set($this->keyPrefix . $id, $data, ['ex' => $this->sessionExpiration])) { $this->fingerprint = $fingerprint; $this->keyExists = true; diff --git a/system/Session/Session.php b/system/Session/Session.php index 1998a0ea8596..65b1e87558d8 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -186,26 +186,20 @@ protected function configure() } /** - * Configure session ID length. + * Configure session ID length. To make life easier, we force the PHP defaults because PHP 9 forces them. * - * To make life easier, we force the PHP defaults. Because PHP9 forces them. - * See https://wiki.php.net/rfc/deprecations_php_8_4#sessionsid_length_and_sessionsid_bits_per_character + * @see https://wiki.php.net/rfc/deprecations_php_8_4#sessionsid_length_and_sessionsid_bits_per_character * * @return void */ protected function configureSidLength() { - $bitsPerCharacter = (int) ini_get('session.sid_bits_per_character'); - $sidLength = (int) ini_get('session.sid_length'); + if ((int) ini_get('session.sid_bits_per_character') !== 4) { + ini_set('session.sid_bits_per_character', '4'); + } - // We force the PHP defaults. - if (PHP_VERSION_ID < 90000) { - if ($bitsPerCharacter !== 4) { - ini_set('session.sid_bits_per_character', '4'); - } - if ($sidLength !== 32) { - ini_set('session.sid_length', '32'); - } + if ((int) ini_get('session.sid_length') !== 32) { + ini_set('session.sid_length', '32'); } $this->sidRegexp = '[0-9a-f]{32}'; diff --git a/system/Superglobals.php b/system/Superglobals.php index 2175728e862e..ac0ea289bd77 100644 --- a/system/Superglobals.php +++ b/system/Superglobals.php @@ -145,7 +145,7 @@ public function getServerArray(): array /** * Set the entire $_SERVER array. * - * @param array $array + * @param array $array */ public function setServerArray(array $array): self { diff --git a/system/Test/CIUnitTestCase.php b/system/Test/CIUnitTestCase.php index edfa98df5c06..05e4c647f0ed 100644 --- a/system/Test/CIUnitTestCase.php +++ b/system/Test/CIUnitTestCase.php @@ -169,7 +169,7 @@ abstract class CIUnitTestCase extends TestCase * Stores information needed to remove any * rows inserted via $this->hasInDatabase(). * - * @var list> + * @var list> */ protected $insertCache = []; @@ -189,7 +189,7 @@ abstract class CIUnitTestCase extends TestCase * Values to be set in the SESSION global * before running the test. * - * @var array + * @var array */ protected $session = []; @@ -273,6 +273,8 @@ protected function tearDown(): void // Check for other trait methods $this->callTraitMethods('tearDown'); + + $this->resetIsWindowsMock(); } /** @@ -283,9 +285,7 @@ protected function tearDown(): void */ private function callTraitMethods(string $stage): void { - if ($this->traits === null) { - $this->traits = class_uses_recursive($this); - } + $this->traits ??= class_uses_recursive($this); foreach ($this->traits as $trait) { $method = $stage . class_basename($trait); @@ -320,6 +320,14 @@ protected function resetServices(bool $initAutoloader = true) Services::reset($initAutoloader); } + /** + * Resets the mocked is_windows() function back to default state. + */ + protected function resetIsWindowsMock(): void + { + is_windows(null); + } + /** * Injects the mock Cache driver to prevent filesystem collisions. * diff --git a/system/Test/Constraints/SeeInDatabase.php b/system/Test/Constraints/SeeInDatabase.php index d1025bc13d08..ef795274ed5d 100644 --- a/system/Test/Constraints/SeeInDatabase.php +++ b/system/Test/Constraints/SeeInDatabase.php @@ -34,12 +34,14 @@ class SeeInDatabase extends Constraint /** * Data used to compare results against. * - * @var array + * @var array */ protected $data; /** * SeeInDatabase constructor. + * + * @param array $data */ public function __construct(ConnectionInterface $db, array $data) { diff --git a/system/Test/ControllerTestTrait.php b/system/Test/ControllerTestTrait.php index 2a7be526383d..01c42458969e 100644 --- a/system/Test/ControllerTestTrait.php +++ b/system/Test/ControllerTestTrait.php @@ -76,7 +76,7 @@ trait ControllerTestTrait /** * URI of this request. * - * @var string + * @var string|URI */ protected $uri = 'http://example.com'; @@ -144,7 +144,7 @@ public function controller(string $name) /** * Runs the specified method on the controller and returns the results. * - * @param array $params + * @param mixed ...$params * * @return TestResponse * diff --git a/system/Test/DOMParser.php b/system/Test/DOMParser.php index 52765a4d656b..0afddfa4425e 100644 --- a/system/Test/DOMParser.php +++ b/system/Test/DOMParser.php @@ -198,6 +198,8 @@ public function dontSeeXPath(string $path): bool /** * Search the DOM using an XPath expression. * + * @param list $paths + * * @return DOMNodeList|false */ protected function doXPath(?string $search, string $element, array $paths = []) diff --git a/system/Test/DatabaseTestTrait.php b/system/Test/DatabaseTestTrait.php index 14c47c95cee7..af2a1679328d 100644 --- a/system/Test/DatabaseTestTrait.php +++ b/system/Test/DatabaseTestTrait.php @@ -29,11 +29,11 @@ * Provides functionality for refreshing/seeding * the database during testing. * - * @property BaseConnection $db - * @property list> $insertCache - * @property Seeder|null $seeder - * @property MigrationRunner|null $migrations - * @property list|string|null $namespace + * @property BaseConnection $db + * @property list> $insertCache + * @property Seeder|null $seeder + * @property MigrationRunner|null $migrations + * @property list|string|null $namespace * * @mixin CIUnitTestCase */ diff --git a/system/Test/Fabricator.php b/system/Test/Fabricator.php index b366464d5a26..c0b925d3cf66 100644 --- a/system/Test/Fabricator.php +++ b/system/Test/Fabricator.php @@ -37,7 +37,7 @@ class Fabricator /** * Array of counts for fabricated items * - * @var array + * @var array */ protected static $tableCounts = []; @@ -65,28 +65,28 @@ class Fabricator /** * Map of properties and their formatter to use * - * @var array|null + * @var array|null */ protected $formatters; /** * Date fields present in the model * - * @var array + * @var list */ protected $dateFields = []; /** * Array of data to add or override faked versions * - * @var array + * @var array */ protected $overrides = []; /** * Array of single-use data to override faked versions * - * @var array|null + * @var array|null */ protected $tempOverrides; @@ -111,9 +111,9 @@ class Fabricator /** * Store the model instance and initialize Faker to the locale. * - * @param object|string $model Instance or classname of the model to use - * @param array|null $formatters Array of property => formatter - * @param string|null $locale Locale for Faker provider + * @param object|string $model Instance or classname of the model to use + * @param array|null $formatters Array of property => formatter + * @param string|null $locale Locale for Faker provider * * @throws InvalidArgumentException */ @@ -129,10 +129,7 @@ public function __construct($model, ?array $formatters = null, ?string $locale = $this->model = $model; - // If no locale was specified then use the App default - if ($locale === null) { - $locale = config(App::class)->defaultLocale; - } + $locale ??= config(App::class)->defaultLocale; // There is no easy way to retrieve the locale from Faker so we will store it $this->locale = $locale; @@ -238,6 +235,8 @@ public function getFaker(): Generator /** * Return and reset tempOverrides + * + * @return array */ public function getOverrides(): array { @@ -251,8 +250,8 @@ public function getOverrides(): array /** * Set the overrides, once or persistent * - * @param array $overrides Array of [field => value] - * @param bool $persist Whether these overrides should persist through the next operation + * @param array $overrides Array of [field => value] + * @param bool $persist Whether these overrides should persist through the next operation */ public function setOverrides(array $overrides = [], $persist = true): self { @@ -307,6 +306,8 @@ public function setValid(string $field, ?Closure $validator = null, int $maxRetr /** * Returns the current formatters + * + * @return array|null */ public function getFormatters(): ?array { @@ -316,7 +317,7 @@ public function getFormatters(): ?array /** * Set the formatters to use. Will attempt to autodetect if none are available. * - * @param array|null $formatters Array of [field => formatter], or null to detect + * @param array|null $formatters Array of [field => formatter], or null to detect */ public function setFormatters(?array $formatters = null): self { @@ -399,7 +400,7 @@ protected function guessFormatter($field): string * * @param int|null $count Optional number to create a collection * - * @return array|object An array or object (based on returnType), or an array of returnTypes + * @return array|list|object>|object An array or object (based on returnType), or an array of returnTypes */ public function make(?int $count = null) { @@ -424,7 +425,7 @@ public function make(?int $count = null) /** * Generate an array of faked data * - * @return array An array of faked data + * @return array An array of faked data * * @throws RuntimeException */ @@ -534,7 +535,7 @@ public function makeObject(?string $className = null): object * @param int|null $count Optional number to create a collection * @param bool $mock Whether to execute or mock the insertion * - * @return array|object An array or object (based on returnType), or an array of returnTypes + * @return array|list|object>|object|null An array or object (based on returnType), or an array of returnTypes * * @throws FrameworkException */ @@ -572,7 +573,7 @@ public function create(?int $count = null, bool $mock = false) * * @param int|null $count Optional number to create a collection * - * @return array|object An array or object (based on returnType), or an array of returnTypes + * @return array|list|object>|object An array or object (based on returnType), or an array of returnTypes */ protected function createMock(?int $count = null) { diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index 087fc0a59d90..6067aa660928 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -35,7 +35,7 @@ * Provides additional utilities for doing full HTTP testing * against your application in trait format. * - * @property array $session + * @property array $session * @property array|string> $headers * @property RouteCollection|null $routes * @@ -104,7 +104,7 @@ protected function withRoutes(?array $routes = null) /** * Sets any values that should exist during this session. * - * @param array|null $values Array of values, or null to use the current $_SESSION + * @param array|null $values Array of values, or null to use the current $_SESSION * * @return $this */ @@ -179,7 +179,8 @@ 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 string $method HTTP verb + * @param array|null $params * * @return TestResponse */ @@ -242,7 +243,8 @@ public function call(string $method, string $path, ?array $params = null) /** * Performs a GET request. * - * @param string $path URI path relative to baseURL. May include query. + * @param string $path URI path relative to baseURL. May include query. + * @param array|null $params * * @return TestResponse * @@ -257,6 +259,8 @@ public function get(string $path, ?array $params = null) /** * Performs a POST request. * + * @param array|null $params + * * @return TestResponse * * @throws RedirectException @@ -270,6 +274,8 @@ public function post(string $path, ?array $params = null) /** * Performs a PUT request * + * @param array|null $params + * * @return TestResponse * * @throws RedirectException @@ -283,6 +289,8 @@ public function put(string $path, ?array $params = null) /** * Performss a PATCH request * + * @param array|null $params + * * @return TestResponse * * @throws RedirectException @@ -296,6 +304,8 @@ public function patch(string $path, ?array $params = null) /** * Performs a DELETE request. * + * @param array|null $params + * * @return TestResponse * * @throws RedirectException @@ -309,6 +319,8 @@ public function delete(string $path, ?array $params = null) /** * Performs an OPTIONS request. * + * @param array|null $params + * * @return TestResponse * * @throws RedirectException @@ -366,10 +378,8 @@ protected function setupRequest(string $method, ?string $path = null): IncomingR */ protected function setupHeaders(IncomingRequest $request) { - if (! empty($this->headers)) { - foreach ($this->headers as $name => $value) { - $request->setHeader($name, $value); - } + foreach ($this->headers as $name => $value) { + $request->setHeader($name, $value); } return $request; @@ -381,10 +391,11 @@ protected function setupHeaders(IncomingRequest $request) * * Always populate the GET vars based on the URI. * - * @param string $name Superglobal name (lowercase) - * @param non-empty-array|null $params + * @param string $name Superglobal name (lowercase) + * @param IncomingRequest $request + * @param non-empty-array|null $params * - * @return Request + * @return IncomingRequest * * @throws ReflectionException */ @@ -420,7 +431,10 @@ protected function populateGlobals(string $name, Request $request, ?array $param * This allows the body to be formatted in a way that the controller is going to * expect as in the case of testing a JSON or XML API. * - * @param array|null $params The parameters to be formatted and put in the body. + * @param IncomingRequest $request + * @param array|null $params The parameters to be formatted and put in the body. + * + * @return IncomingRequest */ protected function setRequestBody(Request $request, ?array $params = null): Request { diff --git a/system/Test/FilterTestTrait.php b/system/Test/FilterTestTrait.php index 1140adf7dc5e..554c9fd472ef 100644 --- a/system/Test/FilterTestTrait.php +++ b/system/Test/FilterTestTrait.php @@ -44,14 +44,14 @@ trait FilterTestTrait /** * The active IncomingRequest or CLIRequest * - * @var RequestInterface + * @var RequestInterface|null */ protected $request; /** * The active Response instance * - * @var ResponseInterface + * @var ResponseInterface|null */ protected $response; @@ -102,9 +102,7 @@ protected function setUpFilterTestTrait(): void $this->filtersConfig ??= config(FiltersConfig::class); $this->filters ??= new Filters($this->filtersConfig, $this->request, $this->response); - if ($this->collection === null) { - $this->collection = service('routes')->loadRoutes(); - } + $this->collection ??= service('routes')->loadRoutes(); $this->doneFilterSetUp = true; } @@ -128,6 +126,8 @@ protected function getFilterCaller($filter, string $position): Closure throw new InvalidArgumentException('Invalid filter position passed: ' . $position); } + $filterInstances = []; + if ($filter instanceof FilterInterface) { $filterInstances = [$filter]; } @@ -145,8 +145,6 @@ protected function getFilterCaller($filter, string $position): Closure $filterClasses = [$filter]; } - $filterInstances = []; - foreach ($filterClasses as $class) { // Get an instance $filter = new $class(); @@ -178,7 +176,7 @@ protected function getFilterCaller($filter, string $position): Closure if ($result instanceof ResponseInterface) { return $result; } - if (empty($result)) { + if (! is_string($result) || $result === '') { continue; } } diff --git a/system/Test/IniTestTrait.php b/system/Test/IniTestTrait.php index 01f8c89a0675..9093854bf225 100644 --- a/system/Test/IniTestTrait.php +++ b/system/Test/IniTestTrait.php @@ -15,8 +15,14 @@ trait IniTestTrait { + /** + * @var array + */ private array $iniSettings = []; + /** + * @param list $keys + */ private function backupIniValues(array $keys): void { foreach ($keys as $key) { diff --git a/system/Test/Mock/MockBuilder.php b/system/Test/Mock/MockBuilder.php index f95f09495a98..971ee7829a55 100644 --- a/system/Test/Mock/MockBuilder.php +++ b/system/Test/Mock/MockBuilder.php @@ -15,6 +15,9 @@ use CodeIgniter\Database\BaseBuilder; +/** + * @extends BaseBuilder + */ class MockBuilder extends BaseBuilder { /** diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 27af8a1ad213..5b736954a536 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -144,7 +144,7 @@ public function deleteMatching(string $pattern): int public function increment(string $key, int $offset = 1): bool { $key = static::validateKey($key, $this->prefix); - $data = $this->cache[$key] ?: null; + $data = $this->cache[$key] ?? null; if ($data === null) { $data = 0; @@ -162,7 +162,7 @@ public function decrement(string $key, int $offset = 1): bool { $key = static::validateKey($key, $this->prefix); - $data = $this->cache[$key] ?: null; + $data = $this->cache[$key] ?? null; if ($data === null) { $data = 0; diff --git a/system/Test/Mock/MockConnection.php b/system/Test/Mock/MockConnection.php index 7f6aa5240e27..18a3eda45d63 100644 --- a/system/Test/Mock/MockConnection.php +++ b/system/Test/Mock/MockConnection.php @@ -70,7 +70,7 @@ public function shouldReturn(string $method, $return) * Should automatically handle different connections for read/write * queries if needed. * - * @param array|string|null $binds + * @param array|string|null $binds * * @return BaseResult|bool|Query * @@ -128,7 +128,7 @@ public function connect(bool $persistent = false) if (is_array($return)) { // By removing the top item here, we can // get a different value for, say, testing failover connections. - $return = array_shift($this->returnValues['connect']); + return array_shift($this->returnValues['connect']); } return $return; @@ -196,7 +196,7 @@ public function error(): array public function insertID(): int { - return $this->connID->insert_id; + return 0; } /** diff --git a/system/Test/Mock/MockResult.php b/system/Test/Mock/MockResult.php index bc5cd8ac2054..28147af9cd9e 100644 --- a/system/Test/Mock/MockResult.php +++ b/system/Test/Mock/MockResult.php @@ -21,17 +21,12 @@ */ class MockResult extends BaseResult { - /** - * Gets the number of fields in the result set. - */ public function getFieldCount(): int { return 0; } /** - * Generates an array of column names in the result set. - * * @return array{} */ public function getFieldNames(): array @@ -40,8 +35,6 @@ public function getFieldNames(): array } /** - * Generates an array of objects representing field meta-data. - * * @return array{} */ public function getFieldData(): array @@ -49,34 +42,19 @@ public function getFieldData(): array return []; } - /** - * Frees the current result. - * - * @return void - */ public function freeResult() { } /** - * Moves the internal pointer to the desired offset. This is called - * internally before fetching results to make sure the result set - * starts at zero. - * - * @param int $n - * - * @return bool + * @return true */ - public function dataSeek($n = 0) + public function dataSeek(int $n = 0) { return true; } /** - * Returns the result set as an array. - * - * Overridden by driver classes. - * * @return array{} */ protected function fetchAssoc() @@ -85,20 +63,13 @@ protected function fetchAssoc() } /** - * Returns the result set as an object. - * - * @param class-string $className - * * @return object */ - protected function fetchObject($className = stdClass::class) + protected function fetchObject(string $className = stdClass::class) { return new $className(); } - /** - * Gets the number of fields in the result set. - */ public function getNumRows(): int { return 0; diff --git a/system/Test/TestResponse.php b/system/Test/TestResponse.php index 13b1df3abaf0..580f938d77af 100644 --- a/system/Test/TestResponse.php +++ b/system/Test/TestResponse.php @@ -357,6 +357,8 @@ public function getJSON() /** * Test that the response contains a matching JSON fragment. + * + * @param array $fragment */ public function assertJSONFragment(array $fragment, bool $strict = false): void { @@ -378,7 +380,7 @@ public function assertJSONFragment(array $fragment, bool $strict = false): void * Asserts that the JSON exactly matches the passed in data. * If the value being passed in is a string, it must be a json_encoded string. * - * @param array|object|string $test + * @param array|object|string $test */ public function assertJSONExact($test): void { diff --git a/system/Throttle/Throttler.php b/system/Throttle/Throttler.php index fba7665de4a8..ce9d673b6f6a 100644 --- a/system/Throttle/Throttler.php +++ b/system/Throttle/Throttler.php @@ -55,7 +55,7 @@ class Throttler implements ThrottlerInterface /** * Timestamp to use (during testing) * - * @var int + * @var int|null */ protected $testTime; diff --git a/system/Traits/ConditionalTrait.php b/system/Traits/ConditionalTrait.php index b24fbdcd1b0c..37703189615d 100644 --- a/system/Traits/ConditionalTrait.php +++ b/system/Traits/ConditionalTrait.php @@ -23,8 +23,6 @@ trait ConditionalTrait * @param TWhen $condition * @param callable(self, TWhen): mixed $callback * @param (callable(self): mixed)|null $defaultCallback - * - * @return $this */ public function when($condition, callable $callback, ?callable $defaultCallback = null): self { @@ -45,8 +43,6 @@ public function when($condition, callable $callback, ?callable $defaultCallback * @param TWhenNot $condition * @param callable(self, TWhenNot): mixed $callback * @param (callable(self): mixed)|null $defaultCallback - * - * @return $this */ public function whenNot($condition, callable $callback, ?callable $defaultCallback = null): self { diff --git a/system/Traits/PropertiesTrait.php b/system/Traits/PropertiesTrait.php index 30b3f86690c0..26bb4bf322f4 100644 --- a/system/Traits/PropertiesTrait.php +++ b/system/Traits/PropertiesTrait.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Traits; +use Closure; use ReflectionClass; use ReflectionProperty; @@ -28,7 +29,7 @@ trait PropertiesTrait /** * Attempts to set the values of public class properties. * - * @return $this + * @param array $params */ final public function fill(array $params): self { @@ -43,21 +44,22 @@ final public function fill(array $params): self /** * Get the public properties of the class and return as an array. + * + * @return array */ final public function getPublicProperties(): array { - $worker = new class () { - public function getProperties(object $obj): array - { - return get_object_vars($obj); - } - }; + $fn = fn (): array => get_object_vars($this); - return $worker->getProperties($this); + $bound = Closure::bind($fn, $this, null); + + return $bound(); } /** * Get the protected and private properties of the class and return as an array. + * + * @return list */ final public function getNonPublicProperties(): array { diff --git a/system/Typography/Typography.php b/system/Typography/Typography.php index 58bb10b0df5a..66a13a0b3f06 100644 --- a/system/Typography/Typography.php +++ b/system/Typography/Typography.php @@ -46,7 +46,7 @@ class Typography /** * array of block level elements that require inner content to be within another block level element * - * @var array + * @var list */ public $innerBlockRequired = ['blockquote']; @@ -236,44 +236,42 @@ public function formatCharacters(string $str): string { static $table; - if (! isset($table)) { - $table = [ - // nested smart quotes, opening and closing - // note that rules for grammar (English) allow only for two levels deep - // and that single quotes are _supposed_ to always be on the outside - // but we'll accommodate both - // Note that in all cases, whitespace is the primary determining factor - // on which direction to curl, with non-word characters like punctuation - // being a secondary factor only after whitespace is addressed. - '/\'"(\s|$)/' => '’”$1', - '/(^|\s|

)\'"/' => '$1‘“', - '/\'"(\W)/' => '’”$1', - '/(\W)\'"/' => '$1‘“', - '/"\'(\s|$)/' => '”’$1', - '/(^|\s|

)"\'/' => '$1“‘', - '/"\'(\W)/' => '”’$1', - '/(\W)"\'/' => '$1“‘', - // single quote smart quotes - '/\'(\s|$)/' => '’$1', - '/(^|\s|

)\'/' => '$1‘', - '/\'(\W)/' => '’$1', - '/(\W)\'/' => '$1‘', - // double quote smart quotes - '/"(\s|$)/' => '”$1', - '/(^|\s|

)"/' => '$1“', - '/"(\W)/' => '”$1', - '/(\W)"/' => '$1“', - // apostrophes - '/(\w)\'(\w)/' => '$1’$2', - // Em dash and ellipses dots - '/\s?\-\-\s?/' => '—', - '/(\w)\.{3}/' => '$1…', - // double space after sentences - '/(\W) /' => '$1  ', - // ampersands, if not a character entity - '/&(?!#?[a-zA-Z0-9]{2,};)/' => '&', - ]; - } + $table ??= [ + // nested smart quotes, opening and closing + // note that rules for grammar (English) allow only for two levels deep + // and that single quotes are _supposed_ to always be on the outside + // but we'll accommodate both + // Note that in all cases, whitespace is the primary determining factor + // on which direction to curl, with non-word characters like punctuation + // being a secondary factor only after whitespace is addressed. + '/\'"(\s|$)/' => '’”$1', + '/(^|\s|

)\'"/' => '$1‘“', + '/\'"(\W)/' => '’”$1', + '/(\W)\'"/' => '$1‘“', + '/"\'(\s|$)/' => '”’$1', + '/(^|\s|

)"\'/' => '$1“‘', + '/"\'(\W)/' => '”’$1', + '/(\W)"\'/' => '$1“‘', + // single quote smart quotes + '/\'(\s|$)/' => '’$1', + '/(^|\s|

)\'/' => '$1‘', + '/\'(\W)/' => '’$1', + '/(\W)\'/' => '$1‘', + // double quote smart quotes + '/"(\s|$)/' => '”$1', + '/(^|\s|

)"/' => '$1“', + '/"(\W)/' => '”$1', + '/(\W)"/' => '$1“', + // apostrophes + '/(\w)\'(\w)/' => '$1’$2', + // Em dash and ellipses dots + '/\s?\-\-\s?/' => '—', + '/(\w)\.{3}/' => '$1…', + // double space after sentences + '/(\W) /' => '$1  ', + // ampersands, if not a character entity + '/&(?!#?[a-zA-Z0-9]{2,};)/' => '&', + ]; return preg_replace(array_keys($table), $table, $str); } @@ -316,6 +314,8 @@ protected function formatNewLines(string $str): string * We don't want quotes converted within tags so we'll temporarily convert them to {@DQ} and {@SQ} * and we don't want double dashes converted to emdash entities, so they are marked with {@DD} * likewise double spaces are converted to {@NBS} to prevent entity conversion + * + * @param array $match */ protected function protectCharacters(array $match): string { diff --git a/system/Validation/CreditCardRules.php b/system/Validation/CreditCardRules.php index e484c35f66cb..0bc20f082123 100644 --- a/system/Validation/CreditCardRules.php +++ b/system/Validation/CreditCardRules.php @@ -31,7 +31,7 @@ class CreditCardRules * prefixes - List of possible prefixes for the card * checkdigit - Boolean on whether we should do a modulus10 check on the numbers. * - * @var array + * @var array */ protected $cards = [ 'American Express' => [ @@ -199,8 +199,7 @@ public function valid_cc_number(?string $ccNumber, string $type): bool // Remove any spaces and dashes $ccNumber = str_replace([' ', '-'], '', $ccNumber); - // Non-numeric values cannot be a number...duh - if (! is_numeric($ccNumber)) { + if (! ctype_digit($ccNumber)) { return false; } diff --git a/system/Validation/DotArrayFilter.php b/system/Validation/DotArrayFilter.php index 673fe67ed138..8e059c3661ae 100644 --- a/system/Validation/DotArrayFilter.php +++ b/system/Validation/DotArrayFilter.php @@ -21,10 +21,10 @@ final class DotArrayFilter /** * Creates a new array with only the elements specified in dot array syntax. * - * @param array $indexes The dot array syntax pattern to use for filtering. - * @param array $array The array to filter. + * @param list $indexes The dot array syntax pattern to use for filtering. + * @param array $array The array to filter. * - * @return array The filtered array. + * @return array The filtered array. */ public static function run(array $indexes, array $array): array { @@ -47,10 +47,10 @@ public static function run(array $indexes, array $array): array /** * Used by `run()` to recursively filter the array with wildcards. * - * @param array $indexes The dot array syntax pattern to use for filtering. - * @param array $array The array to filter. + * @param list $indexes The dot array syntax pattern to use for filtering. + * @param array $array The array to filter. * - * @return array The filtered array. + * @return array The filtered array. */ private static function filter(array $indexes, array $array): array { diff --git a/system/Validation/Rules.php b/system/Validation/Rules.php index 7617b283f099..c48465b327e0 100644 --- a/system/Validation/Rules.php +++ b/system/Validation/Rules.php @@ -28,8 +28,8 @@ class Rules /** * The value does not match another field in $data. * - * @param string|null $str - * @param array $data Other field/value pairs + * @param string|null $str + * @param array $data Other field/value pairs */ public function differs($str, string $field, array $data): bool { @@ -110,12 +110,14 @@ public function greater_than_equal_to($str, string $min): bool * Can ignore records by field/value to filter (currently * accept only one filter). * - * Example: + * ``` * is_not_unique[dbGroup.table.field,where_field,where_value] * is_not_unique[table.field,where_field,where_value] * is_not_unique[menu.id,active,1] + * ``` * - * @param string|null $str + * @param string|null $str + * @param array $data */ public function is_not_unique($str, string $field, array $data): bool { @@ -158,7 +160,8 @@ public function in_list($value, string $list): bool * is_unique[table.field,ignore_field,ignore_value] * is_unique[users.email,id,5] * - * @param string|null $str + * @param string|null $str + * @param array $data */ public function is_unique($str, string $field, array $data): bool { @@ -178,9 +181,9 @@ public function is_unique($str, string $field, array $data): bool /** * Prepares the database query for uniqueness checks. * - * @param mixed $value The value to check. - * @param string $field The field parameters. - * @param array $data Additional data. + * @param mixed $value The value to check. + * @param string $field The field parameters. + * @param array $data Additional data. * * @return array{0: BaseBuilder, 1: string|null, 2: string|null} */ @@ -247,8 +250,8 @@ public function less_than_equal_to($str, string $max): bool /** * Matches the value of another field in $data. * - * @param string|null $str - * @param array $data Other field/value pairs + * @param string|null $str + * @param array $data Other field/value pairs */ public function matches($str, string $field, array $data): bool { @@ -343,9 +346,9 @@ public function required($str = null): bool * * required_with[password] * - * @param string|null $str - * @param string|null $fields List of fields that we should check if present - * @param array $data Complete list of fields from the form + * @param string|null $str + * @param string|null $fields List of fields that we should check if present + * @param array $data Complete list of fields from the form */ public function required_with($str = null, ?string $fields = null, array $data = []): bool { @@ -369,8 +372,8 @@ public function required_with($str = null, ?string $fields = null, array $data = foreach (explode(',', $fields) as $field) { if ( - (array_key_exists($field, $data) && ! empty($data[$field])) - || (str_contains($field, '.') && ! empty(dot_array_search($field, $data))) + (array_key_exists($field, $data) && ! $this->isBlank($data[$field])) + || (str_contains($field, '.') && ! $this->isBlank(dot_array_search($field, $data))) ) { $requiredFields[] = $field; } @@ -387,9 +390,10 @@ public function required_with($str = null, ?string $fields = null, array $data = * * required_without[id,email] * - * @param string|null $str - * @param string|null $otherFields The param fields of required_without[]. - * @param string|null $field This rule param fields aren't present, this field is required. + * @param string|null $str + * @param string|null $otherFields The param fields of required_without[]. + * @param string|null $field This rule param fields aren't present, this field is required. + * @param array $data */ public function required_without( $str = null, @@ -417,7 +421,7 @@ public function required_without( if ( (! str_contains($otherField, '.')) && (! array_key_exists($otherField, $data) - || empty($data[$otherField])) + || $this->isBlank($data[$otherField])) ) { return false; } @@ -432,7 +436,7 @@ public function required_without( $fieldKey = $fieldSplitArray[1] ?? null; if (is_array($fieldData)) { - if (empty($fieldData[$fieldKey])) { + if ($this->isBlank($fieldData[$fieldKey] ?? null)) { return false; } @@ -454,10 +458,10 @@ public function required_without( /** * The field exists in $data. * - * @param mixed $value The field value. - * @param string|null $param The rule's parameter. - * @param array $data The data to be validated. - * @param string|null $field The field name. + * @param mixed $value The field value. + * @param string|null $param The rule's parameter. + * @param array $data The data to be validated. + * @param string|null $field The field name. */ public function field_exists( $value = null, @@ -472,4 +476,15 @@ public function field_exists( return array_key_exists($field, $data); } + + /** + * Whether the value counts as blank for the `required_with` and + * `required_without` rules. + * + * @param mixed $value + */ + private function isBlank($value): bool + { + return in_array($value, [null, false, 0, 0.0, '', '0', []], true); + } } diff --git a/system/Validation/StrictRules/FileRules.php b/system/Validation/StrictRules/FileRules.php index d381f4f47301..f84275432d54 100644 --- a/system/Validation/StrictRules/FileRules.php +++ b/system/Validation/StrictRules/FileRules.php @@ -54,9 +54,7 @@ public function __construct(?RequestInterface $request = null) public function uploaded(?string $blank, string $name): bool { $files = $this->request->getFileMultiple($name); - if ($files === null) { - $files = [$this->request->getFile($name)]; - } + $files ??= [$this->request->getFile($name)]; foreach ($files as $file) { if ($file === null) { @@ -94,9 +92,7 @@ public function max_size(?string $blank, string $params): bool $name = array_shift($paramArray); $files = $this->request->getFileMultiple($name); - if ($files === null) { - $files = [$this->request->getFile($name)]; - } + $files ??= [$this->request->getFile($name)]; foreach ($files as $file) { if ($file === null) { @@ -104,7 +100,7 @@ public function max_size(?string $blank, string $params): bool } if ($file->getError() === UPLOAD_ERR_NO_FILE) { - return true; + continue; } if ($file->getError() === UPLOAD_ERR_INI_SIZE) { @@ -131,9 +127,7 @@ public function is_image(?string $blank, string $params): bool $name = array_shift($params); $files = $this->request->getFileMultiple($name); - if ($files === null) { - $files = [$this->request->getFile($name)]; - } + $files ??= [$this->request->getFile($name)]; foreach ($files as $file) { if ($file === null) { @@ -141,7 +135,7 @@ public function is_image(?string $blank, string $params): bool } if ($file->getError() === UPLOAD_ERR_NO_FILE) { - return true; + continue; } // We know that our mimes list always has the first mime @@ -155,6 +149,10 @@ public function is_image(?string $blank, string $params): bool if ($this->hasInvalidImageClientExtension($file)) { return false; } + + if ($this->hasUnsafeClientFilename($file)) { + return false; + } } return true; @@ -171,9 +169,7 @@ public function mime_in(?string $blank, string $params): bool $name = array_shift($params); $files = $this->request->getFileMultiple($name); - if ($files === null) { - $files = [$this->request->getFile($name)]; - } + $files ??= [$this->request->getFile($name)]; foreach ($files as $file) { if ($file === null) { @@ -181,7 +177,7 @@ public function mime_in(?string $blank, string $params): bool } if ($file->getError() === UPLOAD_ERR_NO_FILE) { - return true; + continue; } if (! in_array($file->getMimeType(), $params, true)) { @@ -191,6 +187,10 @@ public function mime_in(?string $blank, string $params): bool if ($this->hasMismatchedClientExtension($file)) { return false; } + + if ($this->hasUnsafeClientFilename($file)) { + return false; + } } return true; @@ -207,9 +207,7 @@ public function ext_in(?string $blank, string $params): bool $name = array_shift($params); $files = $this->request->getFileMultiple($name); - if ($files === null) { - $files = [$this->request->getFile($name)]; - } + $files ??= [$this->request->getFile($name)]; foreach ($files as $file) { if ($file === null) { @@ -217,7 +215,7 @@ public function ext_in(?string $blank, string $params): bool } if ($file->getError() === UPLOAD_ERR_NO_FILE) { - return true; + continue; } // Check the real filename extension, not only the guessed extension. @@ -230,6 +228,10 @@ public function ext_in(?string $blank, string $params): bool if ($file->guessExtension() !== $clientExtension) { return false; } + + if ($this->hasUnsafeClientFilename($file)) { + return false; + } } return true; @@ -247,9 +249,7 @@ public function max_dims(?string $blank, string $params): bool $name = array_shift($params); $files = $this->request->getFileMultiple($name); - if ($files === null) { - $files = [$this->request->getFile($name)]; - } + $files ??= [$this->request->getFile($name)]; foreach ($files as $file) { if ($file === null) { @@ -257,7 +257,11 @@ public function max_dims(?string $blank, string $params): bool } if ($file->getError() === UPLOAD_ERR_NO_FILE) { - return true; + continue; + } + + if ($file->getError() !== UPLOAD_ERR_OK) { + return false; } // Get Parameter sizes @@ -295,9 +299,7 @@ public function min_dims(?string $blank, string $params): bool $name = array_shift($params); $files = $this->request->getFileMultiple($name); - if ($files === null) { - $files = [$this->request->getFile($name)]; - } + $files ??= [$this->request->getFile($name)]; foreach ($files as $file) { if ($file === null) { @@ -305,7 +307,11 @@ public function min_dims(?string $blank, string $params): bool } if ($file->getError() === UPLOAD_ERR_NO_FILE) { - return true; + continue; + } + + if ($file->getError() !== UPLOAD_ERR_OK) { + return false; } // Get Parameter sizes @@ -360,4 +366,35 @@ private function hasMismatchedClientExtension(UploadedFile $file): bool return $file->guessExtension() !== $clientExtension; } + + /** + * Reject trailing-dot names and names with a PHP extension before the final extension. + */ + private function hasUnsafeClientFilename(UploadedFile $file): bool + { + helper('security'); + + // Check the name that UploadedFile::move() uses when no name is supplied. + $filename = sanitize_filename($file->getClientName()); + $parts = explode('.', strtolower(rtrim($filename, ' '))); + + if (count($parts) === 1) { + return false; + } + + if (array_pop($parts) === '') { + return true; + } + + // Some server configurations select a PHP handler from an earlier extension. + array_shift($parts); + + foreach ($parts as $part) { + if (preg_match('/^php[0-9]*$/', $part) === 1 || in_array($part, ['pht', 'phtml', 'phar', 'phps'], true)) { + return true; + } + } + + return false; + } } diff --git a/system/Validation/StrictRules/Rules.php b/system/Validation/StrictRules/Rules.php index 6d8dce14588b..cca4fdf494be 100644 --- a/system/Validation/StrictRules/Rules.php +++ b/system/Validation/StrictRules/Rules.php @@ -33,8 +33,8 @@ public function __construct() /** * The value does not match another field in $data. * - * @param mixed $str - * @param array $data Other field/value pairs + * @param mixed $str + * @param array $data Other field/value pairs */ public function differs( $str, @@ -137,7 +137,8 @@ public function greater_than_equal_to($str, string $min): bool * is_not_unique[table.field,where_field,where_value] * is_not_unique[menu.id,active,1] * - * @param mixed $str + * @param mixed $str + * @param array $data */ public function is_not_unique($str, string $field, array $data): bool { @@ -176,7 +177,8 @@ public function in_list($value, string $list): bool * is_unique[table.field,ignore_field,ignore_value] * is_unique[users.email,id,5] * - * @param mixed $str + * @param mixed $str + * @param array $data */ public function is_unique($str, string $field, array $data): bool { @@ -226,8 +228,8 @@ public function less_than_equal_to($str, string $max): bool /** * Matches the value of another field in $data. * - * @param mixed $str - * @param array $data Other field/value pairs + * @param mixed $str + * @param array $data Other field/value pairs */ public function matches( $str, @@ -339,9 +341,9 @@ public function required($str = null): bool * * required_with[password] * - * @param mixed $str - * @param string|null $fields List of fields that we should check if present - * @param array $data Complete list of fields from the form + * @param mixed $str + * @param string|null $fields List of fields that we should check if present + * @param array $data Complete list of fields from the form */ public function required_with($str = null, ?string $fields = null, array $data = []): bool { @@ -356,9 +358,10 @@ public function required_with($str = null, ?string $fields = null, array $data = * * required_without[id,email] * - * @param mixed $str - * @param string|null $otherFields The param fields of required_without[]. - * @param string|null $field This rule param fields aren't present, this field is required. + * @param mixed $str + * @param string|null $otherFields The param fields of required_without[]. + * @param string|null $field This rule param fields aren't present, this field is required. + * @param array $data */ public function required_without( $str = null, @@ -373,10 +376,10 @@ public function required_without( /** * The field exists in $data. * - * @param mixed $value The field value. - * @param string|null $param The rule's parameter. - * @param array $data The data to be validated. - * @param string|null $field The field name. + * @param mixed $value The field value. + * @param string|null $param The rule's parameter. + * @param array $data The data to be validated. + * @param string|null $field The field name. */ public function field_exists( $value = null, diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 38210fbdb705..b7c702aee6bc 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -14,7 +14,6 @@ namespace CodeIgniter\Validation; use Closure; -use CodeIgniter\Database\BaseConnection; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\LogicException; use CodeIgniter\HTTP\Exceptions\HTTPException; @@ -34,22 +33,20 @@ class Validation implements ValidationInterface /** * Files to load with validation functions. * - * @var array + * @var list */ protected $ruleSetFiles; /** * The loaded instances of our validation files. * - * @var array + * @var array */ protected $ruleSetInstances = []; /** * Stores the actual rules that should be run against $data. - * - * @var array}> - * + * ``` * [ * field1 => [ * 'label' => label, @@ -58,6 +55,9 @@ class Validation implements ValidationInterface * ], * ], * ] + * ``` + * + * @var array=, string|null=, string|null=): (bool|string))|string>}> */ protected $rules = []; @@ -65,14 +65,14 @@ class Validation implements ValidationInterface * The data that should be validated, * where 'key' is the alias, with value. * - * @var array + * @var array */ protected $data = []; /** * The data that was actually validated. * - * @var array + * @var array */ protected $validated = []; @@ -80,7 +80,7 @@ class Validation implements ValidationInterface * Any generated errors during validation. * 'key' is the alias, 'value' is the message. * - * @var array + * @var array */ protected $errors = []; @@ -88,7 +88,7 @@ class Validation implements ValidationInterface * Stores custom error message to use * during validation. Where 'key' is the alias. * - * @var array + * @var array> */ protected $customErrors = []; @@ -122,14 +122,6 @@ public function __construct($config, RendererInterface $view) $this->loadRuleSets(); } - /** - * Runs the validation process, returning true/false determining whether - * validation was successful or not. - * - * @param array|null $data The array of data to validate. - * @param string|null $group The predefined group of rules to apply. - * @param array|BaseConnection|non-empty-string|null $dbGroup The database group to use. - */ public function run(?array $data = null, ?string $group = null, $dbGroup = null): bool { if ($data === null) { @@ -165,10 +157,6 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null) $rules = $setup['rules']; - if (is_string($rules)) { - $rules = $this->splitRules($rules); - } - if (str_contains($field, '*')) { $flattenedArray = array_flatten_with_dots($data); @@ -238,15 +226,6 @@ private static function getRegex(string $field): string . '\z/'; } - /** - * Runs the validation process, returning true or false determining whether - * validation was successful or not. - * - * @param mixed $value The data to validate. - * @param array|string $rules The validation rules. - * @param list $errors The custom error message. - * @param string|null $dbGroup The database group to use. - */ public function check($value, $rules, array $errors = [], $dbGroup = null): bool { $this->reset(); @@ -263,9 +242,6 @@ public function check($value, $rules, array $errors = [], $dbGroup = null): bool ); } - /** - * Returns the actual validated data. - */ public function getValidated(): array { return $this->validated; @@ -277,10 +253,10 @@ public function getValidated(): array * the error to $this->errors and moves on to the next, * so that we can collect all of the first errors. * - * @param array|string $value - * @param array $rules - * @param array $data The array of data to validate, with `DBGroup`. - * @param string|null $originalField The original asterisk field name like "foo.*.bar". + * @param array|string $value + * @param list<(Closure(mixed, array=, string|null=, string|null=): (bool|string))|string> $rules + * @param array $data The array of data to validate, with `DBGroup`. + * @param string|null $originalField The original asterisk field name like "foo.*.bar". */ protected function processRules( string $field, @@ -386,9 +362,10 @@ protected function processRules( } /** - * @param array $data The array of data to validate, with `DBGroup`. + * @param list<(Closure(mixed, array=, string|null=, string|null=): (bool|string))|string> $rules + * @param array $data The array of data to validate, with `DBGroup`. * - * @return array|true The modified rules or true if we return early + * @return list<(Closure(mixed, array=, string|null=, string|null=): (bool|string))|string>|true The modified rules or true if we return early */ private function processIfExist(string $field, array $rules, array $data) { @@ -427,10 +404,11 @@ private function processIfExist(string $field, array $rules, array $data) } /** - * @param array|string $value - * @param array $data The array of data to validate, with `DBGroup`. + * @param array|string $value + * @param list<(Closure(mixed, array=, string|null=, string|null=): (bool|string))|string> $rules + * @param array $data The array of data to validate, with `DBGroup`. * - * @return array|true The modified rules or true if we return early + * @return list<(Closure(mixed, array=, string|null=, string|null=): (bool|string))|string>|true The modified rules or true if we return early */ private function processPermitEmpty($value, array $rules, array $data) { @@ -474,7 +452,7 @@ private function processPermitEmpty($value, array $rules, array $data) } /** - * @param Closure(bool|float|int|list|object|string|null, bool|float|int|list|object|string|null, string|null, string|null): (bool|string) $rule + * @param Closure(mixed, array=, string|null=, string|null=): (bool|string) $rule */ private function isClosure($rule): bool { @@ -483,6 +461,8 @@ private function isClosure($rule): bool /** * Is the array a string list `list`? + * + * @param array $array */ private function isStringList(array $array): bool { @@ -535,24 +515,6 @@ public function withRequest(RequestInterface $request): ValidationInterface return $this; } - /** - * Sets (or adds) an individual rule and custom error messages for a single - * field. - * - * The custom error message should be just the messages that apply to - * this field, like so: - * [ - * 'rule1' => 'message1', - * 'rule2' => 'message2', - * ] - * - * @param array|string $rules The validation rules. - * @param array $errors The custom error message. - * - * @return $this - * - * @throws InvalidArgumentException - */ public function setRule(string $field, ?string $label, $rules, array $errors = []) { if (! is_array($rules) && ! is_string($rules)) { @@ -575,24 +537,6 @@ public function setRule(string $field, ?string $label, $rules, array $errors = [ return $this; } - /** - * Stores the rules that should be used to validate the items. - * - * Rules should be an array formatted like: - * [ - * 'field' => 'rule1|rule2' - * ] - * - * The $errors array should be formatted like: - * [ - * 'field' => [ - * 'rule1' => 'message1', - * 'rule2' => 'message2', - * ], - * ] - * - * @param array $errors An array of custom error messages - */ public function setRules(array $rules, array $errors = []): ValidationInterface { $this->customErrors = $errors; @@ -625,9 +569,6 @@ public function setRules(array $rules, array $errors = []): ValidationInterface return $this; } - /** - * Returns all of the rules currently defined. - */ public function getRules(): array { return $this->rules; @@ -736,20 +677,6 @@ protected function loadRuleSets() } } - /** - * Loads custom rule groups (if set) into the current rules. - * - * Rules can be pre-defined in Config\Validation and can - * be any name, but must all still be an array of the - * same format used with setRules(). Additionally, check - * for {group}_errors for an array of custom error messages. - * - * @param non-empty-string|null $group - * - * @return array [rules, customErrors] - * - * @throws ValidationException - */ public function loadRuleGroup(?string $group = null) { if ($group === null || $group === '') { @@ -791,6 +718,11 @@ public function loadRuleGroup(?string $group = null) * The value of {id} would be replaced with the actual id in the form data: * * 'is_unique[users,email,id,13]' + * + * @param array=, string|null=, string|null=): (bool|string))|string>}> $rules + * @param array $data + * + * @return array=, string|null=, string|null=): (bool|string))|string>}> */ protected function fillPlaceholders(array $rules, array $data): array { @@ -850,6 +782,10 @@ protected function fillPlaceholders(array $rules, array $data): array /** * Retrieves valid placeholder fields. + * + * @param array $data + * + * @return list */ private function retrievePlaceholders(string $rule, array $data): array { @@ -957,6 +893,8 @@ protected function getErrorMessage( /** * Split rules string by pipe operator. + * + * @return list */ protected function splitRules(string $rules): array { @@ -985,7 +923,8 @@ protected function splitRules(string $rules): array ) { // the pipe is inside the brackets causing the closing bracket to // not be included. so, we adjust the rule to include that portion. - $pos = strpos($string, '|', $cursor + strlen($rule) + 1) ?: $length; + $next = strpos($string, '|', $cursor + strlen($rule) + 1); + $pos = $next === false ? $length : $next; $rule = substr($string, $cursor, $pos - $cursor); } diff --git a/system/Validation/ValidationInterface.php b/system/Validation/ValidationInterface.php index 836516d561b8..e90c41ffd341 100644 --- a/system/Validation/ValidationInterface.php +++ b/system/Validation/ValidationInterface.php @@ -25,9 +25,9 @@ interface ValidationInterface * Runs the validation process, returning true/false determining whether * validation was successful or not. * - * @param array|null $data The array of data to validate. - * @param string|null $group The predefined group of rules to apply. - * @param array|BaseConnection|non-empty-string|null $dbGroup The database group to use. + * @param array|null $data The array of data to validate. + * @param string|null $group The predefined group of rules to apply. + * @param array|BaseConnection|non-empty-string|null $dbGroup The database group to use. */ public function run(?array $data = null, ?string $group = null, $dbGroup = null): bool; @@ -35,10 +35,10 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null) * Check; runs the validation process, returning true or false * determining whether or not validation was successful. * - * @param mixed $value Value to validate. - * @param array|string $rules - * @param list $errors - * @param string|null $dbGroup The database group to use. + * @param mixed $value Value to validate. + * @param array|string $rules + * @param array $errors + * @param string|null $dbGroup The database group to use. * * @return bool True if valid, else false. */ @@ -55,13 +55,12 @@ public function withRequest(RequestInterface $request): ValidationInterface; * * The custom error message should be just the messages that apply to * this field, like so: + * ``` + * ['rule1' => 'message', 'rule2' => 'message'] + * ``` * - * [ - * 'rule' => 'message', - * 'rule' => 'message', - * ] - * - * @param array|string $rules + * @param array|string $rules + * @param array $errors * * @return $this */ @@ -69,11 +68,16 @@ public function setRule(string $field, ?string $label, $rules, array $errors = [ /** * Stores the rules that should be used to validate the items. + * + * @param array|string> $rules + * @param array> $messages */ public function setRules(array $rules, array $messages = []): ValidationInterface; /** * Returns all of the rules currently defined. + * + * @return array}> */ public function getRules(): array; @@ -139,7 +143,7 @@ public function reset(): ValidationInterface; * * @param non-empty-string|null $group * - * @return array + * @return list> */ public function loadRuleGroup(?string $group = null); @@ -160,6 +164,8 @@ public function showError(string $field, string $template = 'single'): string; /** * Returns the actual validated data. + * + * @return array */ public function getValidated(): array; } diff --git a/system/View/Cell.php b/system/View/Cell.php index 3a3e9595e3f5..7b0a15853b2f 100644 --- a/system/View/Cell.php +++ b/system/View/Cell.php @@ -67,10 +67,10 @@ public function __construct(CacheInterface $cache) /** * Render a cell, returning its body as a string. * - * @param string $library Cell class and method name. - * @param array|string|null $params Parameters to pass to the method. - * @param int $ttl Number of seconds to cache the cell. - * @param string|null $cacheName Cache item name. + * @param string $library Cell class and method name. + * @param array|string|null $params Parameters to pass to the method. + * @param int $ttl Number of seconds to cache the cell. + * @param string|null $cacheName Cache item name. * * @throws ReflectionException */ @@ -78,9 +78,7 @@ public function render(string $library, $params = null, int $ttl = 0, ?string $c { [$instance, $method] = $this->determineClass($library); - $class = is_object($instance) - ? $instance::class - : null; + $class = $instance::class; $params = $this->prepareParams($params); @@ -118,9 +116,9 @@ public function render(string $library, $params = null, int $ttl = 0, ?string $c * If a string, it should be in the format "key1=value key2=value". * It will be split and returned as an array. * - * @param array|float|string|null $params + * @param array|float|string|null $params * - * @return array + * @return array */ public function prepareParams($params) { @@ -160,6 +158,8 @@ public function prepareParams($params) /** * Given the library string, attempts to determine the class and method * to call. + * + * @return array{object, string} */ protected function determineClass(string $library): array { @@ -198,6 +198,8 @@ protected function determineClass(string $library): array /** * Renders a cell that extends the BaseCell class. + * + * @param array $params */ final protected function renderCell(BaseCell $instance, string $method, array $params): string { @@ -234,6 +236,10 @@ final protected function renderCell(BaseCell $instance, string $method, array $p * Returns the values from $params that match the parameters * for a method, in the order they are defined. This allows * them to be passed directly into the method. + * + * @param array $params + * + * @return list */ private function getMethodParams(BaseCell $instance, string $method, array $params): array { @@ -262,9 +268,9 @@ private function getMethodParams(BaseCell $instance, string $method, array $para * * @todo Determine if this can be refactored to use $this-getMethodParams(). * - * @param object $instance + * @param array $params */ - final protected function renderSimpleClass($instance, string $method, array $params, string $class): string + final protected function renderSimpleClass(object $instance, string $method, array $params, string $class): string { // Try to match up the parameter list we were provided // with the parameter name in the callback method. diff --git a/system/View/Cells/Cell.php b/system/View/Cells/Cell.php index 581499028e4b..3c61214bf95f 100644 --- a/system/View/Cells/Cell.php +++ b/system/View/Cells/Cell.php @@ -71,6 +71,8 @@ public function setView(string $view) * current scope and captures the output buffer instead of * relying on the view service. * + * @param array $data + * * @throws LogicException */ final protected function view(?string $view, array $data = []): string @@ -109,13 +111,16 @@ final protected function view(?string $view, array $data = []): string $foundView = current($candidateViews); - return (function () use ($properties, $foundView): string { + // The parameter is variadic on purpose: func_get_arg() returns the current + // value of a named parameter, which extract() could overwrite with cell data. + return (function (string ...$foundView) use ($properties): string { + $foundView = $foundView[0]; // Keep $foundView available to the view as before. extract($properties); ob_start(); - include $foundView; + include func_get_arg(0); return ob_get_clean(); - })(); + })($foundView); } /** @@ -129,6 +134,10 @@ public function __toString(): string /** * Allows the developer to define computed properties * as methods with `get` prefixed to the protected/private property name. + * + * @param array $properties + * + * @return array */ private function includeComputedProperties(array $properties): array { diff --git a/system/View/Exceptions/ViewException.php b/system/View/Exceptions/ViewException.php index baf6e5b0699b..4f41deea012c 100644 --- a/system/View/Exceptions/ViewException.php +++ b/system/View/Exceptions/ViewException.php @@ -65,6 +65,14 @@ public static function forTagSyntaxError(string $output) return new static(lang('View.tagSyntaxError', [$output])); } + /** + * @return static + */ + public static function forRestrictedConditional(string $tag) + { + return new static(lang('View.restrictedConditional', [$tag])); + } + /** * @return static */ diff --git a/system/View/Filters.php b/system/View/Filters.php index c74e385cdb96..9e57976e575c 100644 --- a/system/View/Filters.php +++ b/system/View/Filters.php @@ -71,7 +71,7 @@ public static function date_modify($value, string $adjustment) */ public static function default($value, string $default): string { - return empty($value) ? $default : $value; + return in_array($value, [null, false, 0, 0.0, '', '0', []], true) ? $default : $value; } /** diff --git a/system/View/Parser.php b/system/View/Parser.php index 5de115f8236b..8aaa1c723493 100644 --- a/system/View/Parser.php +++ b/system/View/Parser.php @@ -15,6 +15,7 @@ use CodeIgniter\View\Exceptions\ViewException; use Config\View as ViewConfig; use ParseError; +use PhpToken; use Psr\Log\LoggerInterface; /** @@ -72,6 +73,27 @@ class Parser extends View */ protected $dataContexts = []; + /** + * Substitution values are temporarily replaced with opaque tokens so they + * cannot be interpreted as Parser syntax by a later substitution pass. + * + * @var array + */ + private array $replacementTokens = []; + + private ?string $replacementTokenPrefix = null; + + /** + * @var list + */ + private array $conditionalSources = []; + + /** + * Whether conditionals in the template being parsed are restricted + * to variables, literals, arithmetic, comparison and logical operators. + */ + private bool $restrictConditionals = false; + /** * Constructor * @@ -103,9 +125,7 @@ public function __construct( public function render(string $view, ?array $options = null, ?bool $saveData = null): string { $start = microtime(true); - if ($saveData === null) { - $saveData = $this->config->saveData; - } + $saveData ??= $this->config->saveData; $fileExt = pathinfo($view, PATHINFO_EXTENSION); $view = ($fileExt === '') ? $view . '.php' : $view; // allow Views as .html, .tpl, etc (from CI3) @@ -135,9 +155,7 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n } } - if ($this->tempData === null) { - $this->tempData = $this->data; - } + $this->tempData ??= $this->data; $template = file_get_contents($file); $output = $this->parse($template, $this->tempData, $options); @@ -171,13 +189,9 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n public function renderString(string $template, ?array $options = null, ?bool $saveData = null): string { $start = microtime(true); - if ($saveData === null) { - $saveData = $this->config->saveData; - } + $saveData ??= $this->config->saveData; - if ($this->tempData === null) { - $this->tempData = $this->data; - } + $this->tempData ??= $this->data; $output = $this->parse($template, $this->tempData, $options); @@ -239,57 +253,94 @@ protected function parse(string $template, array $data = [], ?array $options = n return ''; } - // Remove any possible PHP tags since we don't support it - // and parseConditionals needs it clean anyway... - $template = str_replace([''], ['<?', '?>'], $template); + $replacementState = [ + $this->replacementTokens, + $this->replacementTokenPrefix, + ]; + $this->replacementTokens = []; + $this->replacementTokenPrefix = bin2hex(random_bytes(16)); + + try { + return $this->parseTemplate($template, $data, $options); + } finally { + [ + $this->replacementTokens, + $this->replacementTokenPrefix, + ] = $replacementState; + } + } + + /** + * @param array $data + * @param array|null $options + */ + private function parseTemplate(string $template, array $data, ?array $options): string + { $template = $this->parseComments($template); - $template = $this->extractNoparse($template); - // Replace any conditional code here so we don't have to parse as much - $template = $this->parseConditionals($template); + // Neutralize PHP tags after stripping comments, which can assemble them. + // parseConditionals() evaluates the template as PHP. + $template = str_replace([''], ['<?', '?>'], $template); - // Handle any plugins before normal data, so that - // it can potentially modify any template between its tags. - $template = $this->parsePlugins($template); + $template = $this->extractNoparse($template); - // Parse stack for each parse type (Single and Pairs) - $replaceSingleStack = []; - $replacePairsStack = []; + $restrictConditionals = $this->restrictConditionals; - // loop over the data variables, saving regex and data - // for later replacement. - foreach ($data as $key => $val) { - $escape = true; + // Nested renders must not weaken the restriction of the outer render. + $this->restrictConditionals = $restrictConditionals + || (bool) ($options['restrictConditionals'] ?? $this->config->restrictParserConditionals); - if (is_array($val)) { - $escape = false; - $replacePairsStack[] = [ - 'replace' => $this->parsePair($key, $val, $template), - 'escape' => $escape, - ]; - } else { - $replaceSingleStack[] = [ - 'replace' => $this->parseSingle($key, (string) $val), - 'escape' => $escape, - ]; + try { + // Replace any conditional code here so we don't have to parse as much + $template = $this->parseConditionals($template); + + // Handle any plugins before normal data, so that + // it can potentially modify any template between its tags. + $template = $this->parsePlugins($template); + + // Parse stack for each parse type (Single and Pairs) + $replaceSingleStack = []; + $replacePairsStack = []; + + // loop over the data variables, saving regex and data + // for later replacement. + foreach ($data as $key => $val) { + $escape = true; + + if (is_array($val)) { + $escape = false; + $replacePairsStack[] = [ + 'replace' => $this->parsePair($key, $val, $template), + 'escape' => $escape, + ]; + } else { + $replaceSingleStack[] = [ + 'replace' => $this->parseSingle($key, (string) $val), + 'escape' => $escape, + ]; + } } - } - // Merge both stacks, pairs first + single stacks - // This allows for nested data with the same key to be replaced properly - $replace = array_merge($replacePairsStack, $replaceSingleStack); + // Merge both stacks, pairs first + single stacks + // This allows for nested data with the same key to be replaced properly + $replace = array_merge($replacePairsStack, $replaceSingleStack); - // Loop over each replace array item which - // holds all the data to be replaced - foreach ($replace as $replaceItem) { - // Loop over the actual data to be replaced - foreach ($replaceItem['replace'] as $pattern => $content) { - $template = $this->replaceSingle($pattern, $content, $template, $replaceItem['escape']); + // Loop over each replace array item which + // holds all the data to be replaced + foreach ($replace as $replaceItem) { + // Loop over the actual data to be replaced + foreach ($replaceItem['replace'] as $pattern => $content) { + $template = $this->replaceSingle($pattern, $content, $template, $replaceItem['escape']); + } } - } - return $this->insertNoparse($template); + $template = $this->insertNoparse($template); + + return strtr($template, $this->replacementTokens); + } finally { + $this->restrictConditionals = $restrictConditionals; + } } /** @@ -363,7 +414,9 @@ protected function parsePair(string $variable, array $data, string $template): a $pair = $this->parsePair($key, $val, $match[1]); if ($pair !== []) { - $pairs[array_keys($pair)[0]] = true; + foreach (array_keys($pair) as $pattern) { + $pairs[$pattern] = true; + } $temp = array_merge($temp, $pair); } @@ -476,6 +529,10 @@ protected function parseConditionals(string $template): string // Build the string to replace the `if` statement with. $condition = $match[2]; + if ($this->restrictConditionals && ! $this->isRestrictedCondition($condition)) { + throw ViewException::forRestrictedConditional($match[0]); + } + $statement = $match[1] === 'elseif' ? '' : ''; $template = str_replace($match[0], $statement, $template); } @@ -494,23 +551,90 @@ protected function parseConditionals(string $template): string // Parse the PHP itself, or insert an error so they can debug ob_start(); - if ($this->tempData === null) { - $this->tempData = $this->data; - } + $this->tempData ??= $this->data; - extract($this->tempData); + $this->conditionalSources[] = $template; try { - eval('?>' . $template . 'tempData); + // The source must survive a parser-data variable named $template. + eval('?>' . $this->conditionalSources[count($this->conditionalSources) - 1] . '', '', 'conditionalSources[count($this->conditionalSources) - 1], + )); + } finally { + array_pop($this->conditionalSources); } return ob_get_clean(); } + /** + * Checks that a conditional expression only contains variables, + * literals, arithmetic, comparison and logical operators, and grouping parentheses. + */ + private function isRestrictedCondition(string $condition): bool + { + $allowedTokens = [ + T_VARIABLE, + T_CONSTANT_ENCAPSED_STRING, + T_LNUMBER, + T_DNUMBER, + T_POW, + T_IS_EQUAL, + T_IS_NOT_EQUAL, + T_IS_IDENTICAL, + T_IS_NOT_IDENTICAL, + T_IS_SMALLER_OR_EQUAL, + T_IS_GREATER_OR_EQUAL, + T_BOOLEAN_AND, + T_BOOLEAN_OR, + T_LOGICAL_AND, + T_LOGICAL_OR, + ]; + $valueTokens = [ + T_VARIABLE, + T_CONSTANT_ENCAPSED_STRING, + T_LNUMBER, + T_DNUMBER, + T_STRING, + ]; + + $afterValue = false; + + foreach (PhpToken::tokenize(' $token) { + if ($index === 0 || $token->is(T_WHITESPACE)) { + continue; + } + + if ($token->text === '(') { + // A parenthesis after a value would be a call. + if ($afterValue) { + return false; + } + } elseif ($token->is(T_STRING)) { + if (! in_array(strtolower($token->text), ['true', 'false', 'null'], true)) { + return false; + } + } elseif ($token->is(T_VARIABLE)) { + if ($token->text === '$this') { + return false; + } + } elseif (! $token->is($allowedTokens) && ! in_array($token->text, [')', '<', '>', '!', '+', '-', '*', '/', '%'], true)) { + return false; + } + + $afterValue = $token->is($valueTokens) || $token->text === ')'; + } + + return true; + } + /** * Over-ride the substitution field delimiters. * @@ -543,16 +667,20 @@ public function setConditionalDelimiters($leftDelimiter = '{', $rightDelimiter = * Handles replacing a pseudo-variable with the actual content. Will double-check * for escaping brackets. * - * @param array|string $pattern - * @param string $content - * @param string $template + * @param array|string $pattern + * @param string $content + * @param string $template */ protected function replaceSingle($pattern, $content, $template, bool $escape = false): string { $content = (string) $content; + // Callers enable escaping for scalar data, but not assembled pairs. + // Protect scalar data even when {! !} disables escaping below. + $protectReplacement = $escape; + // Replace the content in the template - return preg_replace_callback($pattern, function ($matches) use ($content, $escape): string { + return preg_replace_callback($pattern, function ($matches) use ($content, $escape, $protectReplacement): string { // Check for {! !} syntax to not escape this one. if ( str_starts_with($matches[0], $this->leftDelimiter . '!') @@ -561,10 +689,31 @@ protected function replaceSingle($pattern, $content, $template, bool $escape = f $escape = false; } - return $this->prepareReplacement($matches, $content, $escape); + $replacement = $this->prepareReplacement($matches, $content, $escape); + + if (! $protectReplacement || $this->replacementTokenPrefix === null) { + return $replacement; + } + + return $this->protectReplacement($replacement); }, (string) $template); } + /** + * Keeps rendered data opaque until all template processing is complete. + */ + private function protectReplacement(string $replacement): string + { + if ($this->replacementTokenPrefix === null) { + return $replacement; + } + + $token = "\x1A{$this->replacementTokenPrefix}:" . count($this->replacementTokens) . "\x1A"; + $this->replacementTokens[$token] = $replacement; + + return $token; + } + /** * Callback used during parse() to apply any filters to the value. * @@ -709,9 +858,19 @@ protected function parsePlugins(string $template) } } - $template = $isPair - ? str_replace($match[0], $callable($match[2], $params), $template) - : str_replace($match[0], $callable($params), $template); + $replacement = $isPair ? $callable($match[2], $params) : $callable($params); + + // Built-in plugins return rendered data. Custom plugins may + // intentionally return template code for subsequent processing. + // Identify the implementation, not the alias applications can override. + if ( + is_string($callable) + && str_starts_with(strtolower(ltrim($callable, '\\')), strtolower(Plugins::class) . '::') + ) { + $replacement = $this->protectReplacement((string) $replacement); + } + + $template = str_replace($match[0], $replacement, $template); } } @@ -721,7 +880,7 @@ protected function parsePlugins(string $template) /** * Makes a new plugin available during the parsing of the template. * - * @param (callable(array): string)|(callable(string, array): string) $callback + * @param (callable(array): string)|(callable(string, array): string) $callback * * @return $this */ diff --git a/system/View/Plugins.php b/system/View/Plugins.php index 7142e44c452e..b546b4a991d8 100644 --- a/system/View/Plugins.php +++ b/system/View/Plugins.php @@ -71,7 +71,7 @@ public static function safeMailto(array $params = []): string /** * Wrap helper function to use as view plugin. * - * @param array|list $params + * @param array|list $params */ public static function lang(array $params = []): string { diff --git a/system/View/Table.php b/system/View/Table.php index ee6057811615..5d4958088b2d 100644 --- a/system/View/Table.php +++ b/system/View/Table.php @@ -260,7 +260,7 @@ public function setSyncRowsWithHeading(bool $orderByKey) * * Ensures a standard associative array format for all cell data * - * @param array $args + * @param array $args * * @return array>|list> */ @@ -503,9 +503,7 @@ protected function _compileTemplate() } foreach ($this->_defaultTemplate() as $field => $template) { - if (! isset($this->template[$field])) { - $this->template[$field] = $template; - } + $this->template[$field] ??= $template; } } diff --git a/system/View/View.php b/system/View/View.php index 83b19a9313f6..ea12ec45bb76 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -239,7 +239,7 @@ public function render(string $view, ?array $options = null, ?bool $saveData = n ob_start(); include $this->renderVars['file']; - return ob_get_clean() ?: ''; + return (string) ob_get_clean(); })(); // Get back current vars @@ -326,12 +326,15 @@ public function renderString(string $view, ?array $options = null, ?bool $saveDa $saveData ??= $this->saveData; $this->prepareTemplateData($saveData); - $output = (function (string $view): string { + // The parameter is variadic on purpose: func_get_arg() returns the current + // value of a named parameter, which extract() could overwrite with view data. + $output = (function (string ...$view): string { + $view = $view[0]; // Keep $view available inside the view as before. extract($this->tempData); ob_start(); - eval('?>' . $view); + eval('?>' . func_get_arg(0)); - return ob_get_clean() ?: ''; + return (string) ob_get_clean(); })($view); $this->logPerformance($start, microtime(true), $this->excerpt($view)); diff --git a/tests/_support/Entity/Cast/CastBase64.php b/tests/_support/Entity/Cast/CastBase64.php index 5bf086d77685..1735d73d6f9d 100644 --- a/tests/_support/Entity/Cast/CastBase64.php +++ b/tests/_support/Entity/Cast/CastBase64.php @@ -15,6 +15,9 @@ use CodeIgniter\Entity\Cast\BaseCast; +/** + * @extends BaseCast + */ class CastBase64 extends BaseCast { /** diff --git a/tests/_support/Entity/Cast/CastBinaryUUID.php b/tests/_support/Entity/Cast/CastBinaryUUID.php index 230f3a31eaf0..10b111d95c34 100644 --- a/tests/_support/Entity/Cast/CastBinaryUUID.php +++ b/tests/_support/Entity/Cast/CastBinaryUUID.php @@ -15,6 +15,9 @@ use CodeIgniter\Entity\Cast\BaseCast; +/** + * @extends BaseCast + */ class CastBinaryUUID extends BaseCast { /** diff --git a/tests/_support/Entity/Cast/CastPassParameters.php b/tests/_support/Entity/Cast/CastPassParameters.php index 3f304e3622a0..d09521a228a7 100644 --- a/tests/_support/Entity/Cast/CastPassParameters.php +++ b/tests/_support/Entity/Cast/CastPassParameters.php @@ -15,6 +15,9 @@ use CodeIgniter\Entity\Cast\BaseCast; +/** + * @extends BaseCast + */ class CastPassParameters extends BaseCast { /** diff --git a/tests/_support/Mock/MockPreparedQuery.php b/tests/_support/Mock/MockPreparedQuery.php index c31dc266d84d..60c9cf926e2d 100644 --- a/tests/_support/Mock/MockPreparedQuery.php +++ b/tests/_support/Mock/MockPreparedQuery.php @@ -14,11 +14,12 @@ namespace Tests\Support\Mock; use CodeIgniter\Database\BasePreparedQuery; +use CodeIgniter\Test\Mock\MockConnection; /** * @internal * - * @extends BasePreparedQuery + * @extends BasePreparedQuery */ final class MockPreparedQuery extends BasePreparedQuery { diff --git a/tests/system/AutoReview/CheckPrLabelsTest.php b/tests/system/AutoReview/CheckPrLabelsTest.php new file mode 100644 index 000000000000..b220f6b548eb --- /dev/null +++ b/tests/system/AutoReview/CheckPrLabelsTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\AutoReview; + +use Nexus\PHPUnit\Tachycardia\Attribute\TimeLimit; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversNothing] +#[Group('AutoReview')] +final class CheckPrLabelsTest extends TestCase +{ + public function testUsageErrorWithUnknownArgument(): void + { + exec('php ./admin/check-pr-labels.php foo 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + public function testUsageErrorWithInvalidVersion(): void + { + exec('php ./admin/check-pr-labels.php 4.7 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + #[TimeLimit(10.0)] + public function testChecksAgainstLatestRelease(): void + { + $this->skipUnlessGhIsAuthenticated(); + + exec('php ./admin/check-pr-labels.php 2>&1', $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertContains($exitCode, [0, 1], "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertMatchesRegularExpression('/Checking PRs merged since v\d+\.\d+\.\d+/', $outputString); + $this->assertMatchesRegularExpression('/Found \d+ merged PRs\./', $outputString); + } + + #[TimeLimit(10.0)] + public function testChecksSinceGivenRelease(): void + { + $this->skipUnlessGhIsAuthenticated(); + + $version = $this->latestChangelogVersion(); + + exec("php ./admin/check-pr-labels.php {$version} 2>&1", $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertContains($exitCode, [0, 1], "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertStringContainsString("Checking PRs merged since v{$version} (", $outputString); + $this->assertMatchesRegularExpression('/Found \d+ merged PRs\./', $outputString); + } + + private function latestChangelogVersion(): string + { + $changelog = (string) file_get_contents('./CHANGELOG.md'); + + if (preg_match('/^## \[v(\d+\.\d+\.\d+)\]/m', $changelog, $matches) !== 1) { + $this->fail('Could not find a version entry in CHANGELOG.md.'); + } + + return $matches[1]; + } + + private function skipUnlessGhIsAuthenticated(): void + { + exec('gh auth status 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->markTestSkipped( + 'The GitHub CLI is not available or not authenticated. This test is expected ' + . 'to be skipped in CI and runs only where `gh` is authenticated, such as on ' + . 'a maintainer\'s machine.', + ); + } + } +} diff --git a/tests/system/AutoReview/ComposerJsonTest.php b/tests/system/AutoReview/ComposerJsonTest.php index ab6b98b3bd64..55f22adb892b 100644 --- a/tests/system/AutoReview/ComposerJsonTest.php +++ b/tests/system/AutoReview/ComposerJsonTest.php @@ -26,8 +26,19 @@ #[Group('AutoReview')] final class ComposerJsonTest extends TestCase { + /** + * @var array + */ private array $devComposer; + + /** + * @var array + */ private array $frameworkComposer; + + /** + * @var array + */ private array $starterComposer; protected function setUp(): void @@ -105,6 +116,10 @@ private function checkSection(string $section, string $component): void ); } + /** + * @param array $fromMain + * @param array $fromComponent + */ private function checkConfig(array $fromMain, array $fromComponent, string $component): void { foreach ($fromMain as $key => $expectedValue) { @@ -124,6 +139,9 @@ private function checkConfig(array $fromMain, array $fromComponent, string $comp } } + /** + * @return array + */ private function getComposerJson(string $path): array { try { diff --git a/tests/system/AutoReview/FrameworkCodeTest.php b/tests/system/AutoReview/FrameworkCodeTest.php index de515fffebf6..812131f80509 100644 --- a/tests/system/AutoReview/FrameworkCodeTest.php +++ b/tests/system/AutoReview/FrameworkCodeTest.php @@ -31,9 +31,14 @@ final class FrameworkCodeTest extends TestCase { /** * Cache of discovered test class names. + * + * @var list */ private static array $testClasses = []; + /** + * @var list + */ private static array $recognizedGroupAttributeNames = [ 'AutoReview', 'CacheLive', @@ -62,7 +67,6 @@ public function testEachTestClassHasCorrectGroupAttributeName(string $class): vo $unrecognizedGroups = array_diff( array_map(static function (ReflectionAttribute $attribute): string { $groupAttribute = $attribute->newInstance(); - assert($groupAttribute instanceof Group); return $groupAttribute->name(); }, $attributes), @@ -80,6 +84,9 @@ public function testEachTestClassHasCorrectGroupAttributeName(string $class): vo )); } + /** + * @return iterable + */ public static function provideEachTestClassHasCorrectGroupAttributeName(): iterable { foreach (self::getTestClasses() as $class) { @@ -87,6 +94,9 @@ public static function provideEachTestClassHasCorrectGroupAttributeName(): itera } } + /** + * @return list + */ private static function getTestClasses(): array { if (self::$testClasses !== []) { diff --git a/tests/system/AutoReview/GenerateChangelogTest.php b/tests/system/AutoReview/GenerateChangelogTest.php new file mode 100644 index 000000000000..cbb7e87e1e5c --- /dev/null +++ b/tests/system/AutoReview/GenerateChangelogTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\AutoReview; + +use Nexus\PHPUnit\Tachycardia\Attribute\TimeLimit; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversNothing] +#[Group('AutoReview')] +final class GenerateChangelogTest extends TestCase +{ + public function testUsageErrorWithoutArguments(): void + { + exec('php ./admin/generate-changelog.php 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + public function testUsageErrorWithInvalidVersion(): void + { + exec('php ./admin/generate-changelog.php 4.7 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + public function testRejectsVersionAlreadyInChangelog(): void + { + $version = $this->latestChangelogVersion(); + + exec("php ./admin/generate-changelog.php {$version} 2>&1", $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString( + "CHANGELOG.md already contains an entry for v{$version}.", + implode("\n", $output), + ); + } + + #[TimeLimit(10.0)] + public function testDryRunGeneratesEntryWithoutWriting(): void + { + $this->skipUnlessGhIsAuthenticated(); + + $version = $this->incrementPatch($this->latestChangelogVersion()); + + exec("php ./admin/generate-changelog.php {$version} --dry-run 2>&1", $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertStringContainsString( + "## [v{$version}](https://github.com/codeigniter4/CodeIgniter4/tree/v{$version})", + $outputString, + ); + $this->assertMatchesRegularExpression( + '~\[Full Changelog\]\(https://github\.com/codeigniter4/CodeIgniter4/compare/v[\d.]+\.\.\.v' . preg_quote($version, '~') . '\)~', + $outputString, + ); + $this->assertSame('', trim((string) exec('git status --porcelain -- CHANGELOG.md'))); + } + + #[TimeLimit(10.0)] + public function testDryRunIncludesSecurityFixesFromDetailedChangelog(): void + { + $this->skipUnlessGhIsAuthenticated(); + + $version = $this->latestVersionWithSecuritySection(); + + if ($version === null) { + $this->markTestSkipped('No detailed changelog with a SECURITY section found.'); + } + + exec("php ./admin/generate-changelog.php {$version} --dry-run 2>&1", $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertMatchesRegularExpression('/### Security\n\n\* /', $outputString); + } + + #[TimeLimit(10.0)] + public function testAddsEntryToChangelog(): void + { + $this->skipUnlessGhIsAuthenticated(); + + if (trim((string) exec('git status --porcelain -- CHANGELOG.md')) !== '') { + $this->markTestSkipped('You have uncommitted changes to CHANGELOG.md that will be erased by this test.'); + } + + $version = $this->incrementPatch($this->latestChangelogVersion()); + + try { + exec("php ./admin/generate-changelog.php {$version} 2>&1", $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertStringContainsString("Added the v{$version} entry to CHANGELOG.md.", $outputString); + + $changelog = (string) file_get_contents('./CHANGELOG.md'); + $this->assertStringStartsWith( + "# Changelog\n\n## [v{$version}](https://github.com/codeigniter4/CodeIgniter4/tree/v{$version})", + $changelog, + ); + } finally { + exec('git restore -- CHANGELOG.md'); + } + } + + private function skipUnlessGhIsAuthenticated(): void + { + exec('gh auth status 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->markTestSkipped( + 'The GitHub CLI is not available or not authenticated. This test is expected ' + . 'to be skipped in CI and runs only where `gh` is authenticated, such as on ' + . 'a maintainer\'s machine.', + ); + } + } + + private function latestChangelogVersion(): string + { + $changelog = (string) file_get_contents('./CHANGELOG.md'); + + if (preg_match('/^## \[v(\d+\.\d+\.\d+)\]/m', $changelog, $matches) !== 1) { + $this->fail('Could not find a version entry in CHANGELOG.md.'); + } + + return $matches[1]; + } + + private function latestVersionWithSecuritySection(): ?string + { + $versions = []; + $paths = glob('./user_guide_src/source/changelogs/v*.rst'); + + if ($paths === false) { + return null; + } + + foreach ($paths as $path) { + if (preg_match('/v(\d+\.\d+\.\d+)\.rst$/', $path, $matches) !== 1) { + continue; + } + + if (str_contains((string) file_get_contents($path), "\nSECURITY\n")) { + $versions[] = $matches[1]; + } + } + + if ($versions === []) { + return null; + } + + usort($versions, version_compare(...)); + + return end($versions); + } + + private function incrementPatch(string $version): string + { + $parts = explode('.', $version); + + return sprintf('%d.%d.%d', $parts[0], $parts[1], ++$parts[2]); + } +} diff --git a/tests/system/AutoReview/SyncReleaseBranchesTest.php b/tests/system/AutoReview/SyncReleaseBranchesTest.php new file mode 100644 index 000000000000..26d90c4b1592 --- /dev/null +++ b/tests/system/AutoReview/SyncReleaseBranchesTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\AutoReview; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * The successful path switches branches and merges, so only the argument + * validation is exercised here. + * + * @internal + */ +#[CoversNothing] +#[Group('AutoReview')] +final class SyncReleaseBranchesTest extends TestCase +{ + #[DataProvider('provideUsageErrors')] + public function testUsageErrors(string $arguments): void + { + exec(sprintf('php ./admin/sync-release-branches.php %s 2>&1', $arguments), $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + /** + * @return iterable + */ + public static function provideUsageErrors(): iterable + { + yield 'no arguments' => ['']; + + yield 'missing source' => ['4.8']; + + yield 'invalid branch' => ['release-4.8.0 develop']; + + yield 'invalid source' => ['4.8 upstream/develop']; + + yield 'same branch and source' => ['develop develop']; + + yield 'unknown option' => ['4.8 develop --force']; + } +} diff --git a/tests/system/AutoReview/UpdateUpgradeGuideTest.php b/tests/system/AutoReview/UpdateUpgradeGuideTest.php new file mode 100644 index 000000000000..f982ac375dfa --- /dev/null +++ b/tests/system/AutoReview/UpdateUpgradeGuideTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\AutoReview; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversNothing] +#[Group('AutoReview')] +final class UpdateUpgradeGuideTest extends TestCase +{ + private string $nextVersion; + private string $upgradePath; + + protected function setUp(): void + { + parent::setUp(); + + exec('git describe --tags --abbrev=0 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->markTestSkipped(sprintf( + "Unable to get the latest git tag.\nOutput: %s", + implode("\n", $output), + )); + } + + $parts = explode('.', trim($output[0], 'v')); + + $this->nextVersion = sprintf('%d.%d.%d', $parts[0], $parts[1], ++$parts[2]); + $this->upgradePath = sprintf( + './user_guide_src/source/installation/upgrade_%s.rst', + str_replace('.', '', $this->nextVersion), + ); + } + + public function testUsageErrorWithoutArguments(): void + { + exec('php ./admin/update-upgrade-guide.php 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + public function testUsageErrorWithInvalidVersion(): void + { + exec('php ./admin/update-upgrade-guide.php 4.7 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + public function testErrorWhenUpgradeGuideIsMissing(): void + { + exec('php ./admin/update-upgrade-guide.php 9.9.8 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('is not found', implode("\n", $output)); + } + + public function testDryRunPrintsSectionsWithoutWriting(): void + { + $this->assertFileExists($this->upgradePath); + + exec(sprintf('php ./admin/update-upgrade-guide.php %s --dry-run 2>&1', $this->nextVersion), $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertMatchesRegularExpression('/Checking the project space files changed since v\d+\.\d+\.\d+\./', $outputString); + $this->assertStringContainsString('All Changes', $outputString); + $this->assertSame('', trim((string) exec("git status --porcelain -- {$this->upgradePath}"))); + } + + public function testUpdatesUpgradeGuide(): void + { + $this->assertFileExists($this->upgradePath); + + if (trim((string) exec("git status --porcelain -- {$this->upgradePath}")) !== '') { + $this->markTestSkipped('You have uncommitted changes to the upgrade guide that will be erased by this test.'); + } + + try { + exec(sprintf('php ./admin/update-upgrade-guide.php %s 2>&1', $this->nextVersion), $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertStringContainsString('"All Changes" section', $outputString); + + $contents = (string) file_get_contents($this->upgradePath); + $allChanges = substr($contents, (int) strpos($contents, "All Changes\n")); + + $this->assertStringNotContainsString('- @TODO', $contents); + $this->assertMatchesRegularExpression('/^- \S/m', $allChanges); + } finally { + exec("git restore -- {$this->upgradePath}"); + } + } +} diff --git a/tests/system/Autoloader/FileLocatorCachedTest.php b/tests/system/Autoloader/FileLocatorCachedTest.php index f8b5b386818c..9700f88bdb33 100644 --- a/tests/system/Autoloader/FileLocatorCachedTest.php +++ b/tests/system/Autoloader/FileLocatorCachedTest.php @@ -75,10 +75,27 @@ protected function tearDown(): void public function testDeleteCache(): void { - $this->assertNotSame([], $this->handler->get('FileLocatorCache')); + $this->locator->search('Config/App'); + $this->locator->__destruct(); + $this->assertIsArray($this->handler->get('FileLocatorCache')); $this->locator->deleteCache(); $this->assertFalse($this->handler->get('FileLocatorCache')); } + + public function testDeleteCacheDiscardsDataHeldInMemory(): void + { + $this->locator->getClassname(SYSTEMPATH . 'CodeIgniter.php'); + + $this->locator->deleteCache(); + + $this->locator->search('Config/App'); + $this->locator->__destruct(); + + $cached = $this->handler->get('FileLocatorCache'); + + $this->assertArrayHasKey('search', $cached); + $this->assertArrayNotHasKey('getClassname', $cached); + } } diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 9391eae05d27..9c697b25afb9 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -17,6 +17,7 @@ use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\PhpStreamWrapper; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\DataProvider; @@ -143,6 +144,166 @@ public function testPromptInputZero(): void $this->assertSame('0', $output); } + public function testPromptPassesPromptTextToInputReader(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return 'red'; + } + }; + CLI::setInputOutput($io); + + $output = CLI::prompt('What is your favorite color?'); + + CLI::resetInputOutput(); + + $this->assertSame('red', $output); + $this->assertSame('What is your favorite color? : ', $io->receivedPrefix); + } + + public function testPromptPassesDefaultOptionInPromptText(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return ''; + } + }; + CLI::setInputOutput($io); + + $output = CLI::prompt('What is your favorite color?', 'red'); + + CLI::resetInputOutput(); + + $this->assertSame('red', $output); + $this->assertSame( + sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green')), + $io->receivedPrefix, + ); + } + + public function testPromptByKeyPassesPromptTextToInputReader(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return '1'; + } + }; + CLI::setInputOutput($io); + + $output = CLI::promptByKey('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']); + + CLI::resetInputOutput(); + + $this->assertSame('1', $output); + $this->assertSame( + PHP_EOL . sprintf('[%s, 1, 2]: ', CLI::color('0', 'green')), + $io->receivedPrefix, + ); + } + + public function testPromptByMultipleKeysPassesPromptTextToInputReader(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return '0,1'; + } + }; + CLI::setInputOutput($io); + + $output = CLI::promptByMultipleKeys('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']); + + CLI::resetInputOutput(); + + $this->assertSame([0 => 'Playing game', 1 => 'Sleep'], $output); + $this->assertSame( + 'You can specify multiple values separated by commas.' . PHP_EOL + . sprintf('[%s, 1, 2] : ', CLI::color('0', 'green')), + $io->receivedPrefix, + ); + } + + public function testInputWritesPrefixToStdout(): void + { + $io = new MockInputOutput(); + $io->setInputs(['blue']); + CLI::setInputOutput($io); + + $output = CLI::input('Name: '); + + CLI::resetInputOutput(); + + $this->assertSame('blue', $output); + $this->assertSame('Name: blue' . PHP_EOL, $io->getOutput()); + } + + public function testMarkAnsiNonPrintingWrapsEscapeSequences(): void + { + $wrap = $this->getPrivateMethodInvoker(new InputOutput(), 'markAnsiNonPrinting'); + + $this->assertSame( + "What is your favorite color? [\x01\e[0;32m\x02red\x01\e[0m\x02]: ", + $wrap(sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green'))), + ); + $this->assertSame('Name: ', $wrap('Name: ')); + } + + #[DataProvider('provideReadlinePromptDependsOnLibrary')] + public function testReadlinePromptDependsOnLibrary(mixed $libraryVersion, ?string $expected): void + { + $build = $this->getPrivateMethodInvoker(new InputOutput(), 'readlinePrompt'); + + $this->assertSame( + $expected, + $build(sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green')), $libraryVersion), + ); + } + + /** + * @return iterable + */ + public static function provideReadlinePromptDependsOnLibrary(): iterable + { + yield 'GNU readline gets the prompt with non-printing markers' => [ + '8.2', + "What is your favorite color? [\x01\e[0;32m\x02red\x01\e[0m\x02]: ", + ]; + + yield 'libedit gets the raw prompt' => [ + 'EditLine wrapper', + "What is your favorite color? [\e[0;32mred\e[0m]: ", + ]; + + // Official Windows builds use WinEditLine, which exposes no library version. + yield 'WinEditLine gets no prompt so the caller writes it' => [null, null]; + } + + public function testReadlinePromptWithoutPrefixReturnsNull(): void + { + $build = $this->getPrivateMethodInvoker(new InputOutput(), 'readlinePrompt'); + + $this->assertNull($build(null, '8.2')); + } + public function testPromptByKey(): void { PhpStreamWrapper::register(); @@ -292,7 +453,10 @@ public function testColorSupportOnHyperTerminals(): void public function testStreamSupports(): void { $this->assertTrue(CLI::streamSupports('stream_isatty', STDOUT)); - $this->assertIsBool(CLI::streamSupports('sapi_windows_vt100_support', STDOUT)); + $this->assertSame( + function_exists('sapi_windows_vt100_support'), + CLI::streamSupports('sapi_windows_vt100_support', STDOUT), + ); } public function testColor(): void @@ -399,6 +563,32 @@ public function testError(): void $this->assertSame($expected, $this->getStreamFilterBuffer()); } + public function testErrorOutsideCliWritesToStdoutWithoutColor(): void + { + $io = new class () extends InputOutput { + /** + * @var list + */ + public array $writes = []; + + public function fwrite($handle, string $string): void + { + $this->writes[] = [$handle === STDERR ? 'STDERR' : 'STDOUT', $string]; + } + }; + CLI::setInputOutput($io); + + is_cli(false); + + try { + CLI::error('test'); + } finally { + is_cli(true); + } + + $this->assertSame([['STDOUT', "\ntest\n"]], $io->writes); + } + public function testMixedWriteError(): void { CLI::write('test 1'); @@ -587,12 +777,12 @@ public function testWindow(): void $height = new ReflectionProperty(CLI::class, 'height'); $height->setValue(null, null); - $this->assertIsInt(CLI::getHeight()); + $this->assertGreaterThan(0, CLI::getHeight()); $width = new ReflectionProperty(CLI::class, 'width'); $width->setValue(null, null); - $this->assertIsInt(CLI::getWidth()); + $this->assertGreaterThan(0, CLI::getWidth()); } #[RequiresOperatingSystem('Darwin|Linux')] @@ -646,18 +836,20 @@ private function captureGenerateDimensionsStderr(?array $env = null): string } /** - * @param array $tbody - * @param array $thead - * @param array $expected + * @param list> $tbody + * @param list $thead */ #[DataProvider('provideTable')] - public function testTable($tbody, $thead, $expected): void + public function testTable(array $tbody, array $thead, string $expected): void { CLI::table($tbody, $thead); $this->assertSame($this->getStreamFilterBuffer(), $expected); } + /** + * @return iterable>, list, string}> + */ public static function provideTable(): iterable { $head = [ diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 34bb0601da65..cd284960909d 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -13,7 +13,9 @@ namespace CodeIgniter\CLI; +use App\Commands\ListCommands as AppListCommands; use CodeIgniter\Autoloader\FileLocatorInterface; +use CodeIgniter\Commands\ListCommands; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use Config\Services; @@ -42,22 +44,6 @@ protected function resetAll(): void CLI::reset(); } - private function copyAppListCommands(): void - { - if (! is_dir(APPPATH . 'Commands')) { - mkdir(APPPATH . 'Commands'); - } - - copy(SUPPORTPATH . '_command/ListCommands.php', APPPATH . 'Commands/ListCommands.php'); - } - - private function deleteAppListCommands(): void - { - if (is_file(APPPATH . 'Commands/ListCommands.php')) { - unlink(APPPATH . 'Commands/ListCommands.php'); - } - } - public function testRunOnUnknownCommand(): void { $commands = new Commands(); @@ -166,13 +152,22 @@ public function testDiscoverCommandsWithNoFiles(): void public function testDiscoveredCommandsCanBeOverridden(): void { - $this->copyAppListCommands(); + // The fixture sits outside any PSR-4 root, so the autoloader cannot load it. + require_once SUPPORTPATH . '_command/ListCommands.php'; + + $files = [ + SUPPORTPATH . '_command/ListCommands.php' => AppListCommands::class, + SYSTEMPATH . 'Commands/ListCommands.php' => ListCommands::class, + ]; + + $locator = $this->createMock(FileLocatorInterface::class); + $locator->method('listFiles')->with('Commands/')->willReturn(array_keys($files)); + $locator->method('findQualifiedNameFromPath')->willReturnCallback(static fn (string $file): string => $files[$file]); + Services::injectMock('locator', $locator); - command('list'); + (new Commands())->run('list', []); $this->assertStringContainsString('This is App\Commands\ListCommands', $this->getStreamFilterBuffer()); $this->assertStringNotContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); - - $this->deleteAppListCommands(); } } diff --git a/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php b/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php index c71c756f5cdd..e09816dba2fa 100644 --- a/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php +++ b/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php @@ -22,9 +22,46 @@ #[Group('Others')] final class FactoriesCacheFileVarExportHandlerTest extends AbstractFactoriesCacheHandlerTestCase { + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + helper('filesystem'); + } + protected function createFactoriesCache(): void { $this->handler = new FileVarExportHandler(); $this->cache = new FactoriesCache($this->handler); } + + public function testSaveCreatesDirectoryAndFileWithCorrectPermissions(): void + { + $dir = WRITEPATH . 'cache_test_dir_' . uniqid('', true); + $oldUmask = umask(0000); + + try { + $handler = new FileVarExportHandler(); + $this->setPrivateProperty($handler, 'path', $dir); + + $handler->save('test_key', ['data']); + + $this->assertDirectoryExists($dir); + + if (! is_windows()) { + $dirPerms = fileperms($dir) & 0777; + $this->assertSame(0755, $dirPerms); + + $filePerms = fileperms($dir . '/test_key') & 0777; + $this->assertSame(0644, $filePerms); + } + } finally { + umask($oldUmask); + + if (is_dir($dir)) { + delete_files($dir); + rmdir($dir); + } + } + } } diff --git a/tests/system/Cache/Handlers/ApcuHandlerTest.php b/tests/system/Cache/Handlers/ApcuHandlerTest.php index 5d5d5f7b2f6f..91176524d038 100644 --- a/tests/system/Cache/Handlers/ApcuHandlerTest.php +++ b/tests/system/Cache/Handlers/ApcuHandlerTest.php @@ -27,30 +27,24 @@ #[RequiresPhpExtension('apcu')] final class ApcuHandlerTest extends AbstractHandlerTestCase { - /** - * @return list - */ - private static function getKeyArray(): array - { - return [ - self::$key1, - self::$key2, - self::$key3, - ]; - } - protected function setUp(): void { parent::setUp(); + if (! function_exists('apcu_enabled') || ! apcu_enabled()) { + $this->markTestSkipped('APCu extension is not loaded or not enabled in CLI.'); + } + $this->handler = CacheFactory::getHandler(new Cache(), 'apcu'); } protected function tearDown(): void { - foreach (self::getKeyArray() as $key) { - $this->handler->delete($key); + if (isset($this->handler)) { + $this->handler->clean(); } + + parent::tearDown(); } public function testNew(): void diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index c4fa8481bbd4..ee26783ad992 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -31,18 +31,6 @@ final class FileHandlerTest extends AbstractHandlerTestCase private static string $directory = 'FileHandler'; private Cache $config; - /** - * @return list - */ - private static function getKeyArray(): array - { - return [ - self::$key1, - self::$key2, - self::$key3, - ]; - } - protected function setUp(): void { parent::setUp(); @@ -69,15 +57,8 @@ protected function tearDown(): void if (is_dir($this->config->file['storePath'])) { chmod($this->config->file['storePath'], 0777); - foreach (self::getKeyArray() as $key) { - if (is_file($this->config->file['storePath'] . DIRECTORY_SEPARATOR . $key)) { - chmod($this->config->file['storePath'] . DIRECTORY_SEPARATOR . $key, 0777); - unlink($this->config->file['storePath'] . DIRECTORY_SEPARATOR . $key); - } - if (is_file($this->config->file['storePath'] . DIRECTORY_SEPARATOR . $this->config->prefix . $key)) { - chmod($this->config->file['storePath'] . DIRECTORY_SEPARATOR . $this->config->prefix . $key, 0777); - unlink($this->config->file['storePath'] . DIRECTORY_SEPARATOR . $this->config->prefix . $key); - } + if (isset($this->handler)) { + $this->handler->clean(); } rmdir($this->config->file['storePath']); @@ -118,7 +99,7 @@ public function testSetDefaultPath(): void */ public function testGet(): void { - $this->handler->save(self::$key1, 'value', 2); + $this->assertTrue($this->handler->save(self::$key1, 'value', 2)); $this->assertSame('value', $this->handler->get(self::$key1)); $this->assertNull($this->handler->get(self::$dummy)); diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index e6bd5dd147ec..e81c8c9056fe 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -26,18 +26,6 @@ #[Group('CacheLive')] final class MemcachedHandlerTest extends AbstractHandlerTestCase { - /** - * @return list - */ - private static function getKeyArray(): array - { - return [ - self::$key1, - self::$key2, - self::$key3, - ]; - } - protected function setUp(): void { parent::setUp(); @@ -51,9 +39,11 @@ protected function setUp(): void protected function tearDown(): void { - foreach (self::getKeyArray() as $key) { - $this->handler->delete($key); + if (isset($this->handler)) { + $this->handler->clean(); } + + parent::tearDown(); } public function testNew(): void @@ -161,7 +151,12 @@ public function testDecrement(): void $this->assertSame(9, $memcachedHandler->decrement(self::$key1, 1)); $this->assertFalse($memcachedHandler->decrement(self::$key2, 1)); - $this->assertSame(1, $memcachedHandler->decrement(self::$key3, 1)); + // A key that doesn't exist yet starts at 0, not at the offset + // (Memcached counters are unsigned, so it can't start negative). + $this->assertSame(0, $memcachedHandler->decrement(self::$key3, 5)); + // Memcached stores counter values as decimal strings on the wire, so + // a plain get() on a counter key returns a string, not an int. + $this->assertSame('0', $memcachedHandler->get(self::$key3)); } public function testClean(): void diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 135d3ff083de..91949f53238f 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -27,18 +27,6 @@ final class PredisHandlerTest extends AbstractHandlerTestCase { private Cache $config; - /** - * @return list - */ - private static function getKeyArray(): array - { - return [ - self::$key1, - self::$key2, - self::$key3, - ]; - } - protected function setUp(): void { parent::setUp(); @@ -49,9 +37,11 @@ protected function setUp(): void protected function tearDown(): void { - foreach (self::getKeyArray() as $key) { - $this->handler->delete($key); + if (isset($this->handler)) { + $this->handler->clean(); } + + parent::tearDown(); } public function testNew(): void diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index d42123c6dd82..1f85ecc28349 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -28,18 +28,6 @@ final class RedisHandlerTest extends AbstractHandlerTestCase { private Cache $config; - /** - * @return list - */ - private static function getKeyArray(): array - { - return [ - self::$key1, - self::$key2, - self::$key3, - ]; - } - protected function setUp(): void { parent::setUp(); @@ -54,9 +42,11 @@ protected function setUp(): void protected function tearDown(): void { - foreach (self::getKeyArray() as $key) { - $this->handler->delete($key); + if (isset($this->handler)) { + $this->handler->clean(); } + + parent::tearDown(); } public function testNew(): void diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index e5d371bfb941..da65ffd4a22d 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -63,6 +63,7 @@ protected function setUp(): void $this->codeigniter = new MockCodeIgniter(new App()); $response = service('response'); + $this->assertInstanceOf(Response::class, $response); $response->pretend(); } @@ -118,7 +119,7 @@ public function testRunClosureRoute(): void // Inject mock router. $routes = service('routes'); - $routes->add('pages/(:segment)', static function ($segment): void { + $routes->add('pages/(:segment)', static function (string $segment = ''): void { echo 'You want to see "' . esc($segment) . '" page.'; }); $router = service('router', $routes, service('incomingrequest')); @@ -230,7 +231,7 @@ public function testControllersCanReturnString(): void $routes = service('routes'); $routes->add( 'pages/(:segment)', - static fn ($segment): string => 'You want to see "' . esc($segment) . '" page.', + static fn (string $segment = ''): string => 'You want to see "' . esc($segment) . '" page.', ); $router = service('router', $routes, service('incomingrequest')); Services::injectMock('router', $router); @@ -252,12 +253,10 @@ public function testControllersCanReturnResponseObject(): void // Inject mock router. $routes = service('routes'); - $routes->add('pages/(:segment)', static function ($segment) { - $response = service('response'); - $string = "You want to see 'about' page."; - - return $response->setBody($string); - }); + $routes->add( + 'pages/(:segment)', + static fn () => service('response')->setBody("You want to see 'about' page."), + ); $router = service('router', $routes, service('incomingrequest')); Services::injectMock('router', $router); @@ -281,11 +280,10 @@ public function testControllersCanReturnDownloadResponseObject(): void // Inject mock router. $routes = service('routes'); - $routes->add('pages/(:segment)', static function ($segment) { - $response = service('response'); - - return $response->download('some.txt', 'some text', true); - }); + $routes->add( + 'pages/(:segment)', + static fn () => service('response')->download('some.txt', 'some text', true), + ); $router = service('router', $routes, service('incomingrequest')); Services::injectMock('router', $router); @@ -834,7 +832,8 @@ public function testPageCacheSendSecureHeaders(): void } /** - * @param array|bool $cacheQueryStringValue + * @param bool|list $cacheQueryStringValue + * @param list $testingUrls * * @see https://github.com/codeigniter4/CodeIgniter4/pull/6410 */ @@ -905,6 +904,9 @@ public function testPageCacheWithCacheQueryString( CITestStreamFilter::removeErrorFilter(); } + /** + * @return iterable, int, list}> + */ public static function providePageCacheWithCacheQueryString(): iterable { $testingUrls = [ @@ -981,10 +983,7 @@ public function testStartControllerPermitsInvoke(): void $startController = self::getPrivateMethodInvoker($this->codeigniter, 'startController'); $this->setPrivateProperty($this->codeigniter, 'method', '__invoke'); - $startController(); - - // No PageNotFoundException - $this->assertTrue(true); + $this->assertNull($startController()); } public function testRouteAttributeCacheIntegration(): void @@ -1309,4 +1308,37 @@ public function testResetForWorkerMode(): void $this->assertSame($csp->getStyleNonce(), RichRenderer::$css_nonce); $this->assertTrue(RichRenderer::$needs_pre_render); } + + public function testGatherOutputCalledOnceWhenControllerReturnsResponse(): void + { + $this->resetServices(); + + $superglobals = service('superglobals'); + $superglobals->setServer('argv', ['index.php', 'pages/test']); + $superglobals->setServer('argc', 2); + $superglobals->setServer('REQUEST_URI', '/pages/test'); + $superglobals->setServer('SCRIPT_NAME', '/index.php'); + + $routes = service('routes'); + $routes->add('pages/test', static fn () => service('response')->setBody('Test Body')); + + $config = new App(); + $codeigniter = new class ($config) extends MockCodeIgniter { + public int $gatherOutputCalls = 0; + + protected function gatherOutput(?Cache $cacheConfig = null, $returned = null): void + { + $this->gatherOutputCalls++; + parent::gatherOutput($cacheConfig, $returned); + } + }; + + ob_start(); + $codeigniter->run($routes); + ob_end_clean(); + + // When startController() returns a ResponseInterface (e.g. from a closure route), + // gatherOutput() must be called exactly once — not twice as in the original bug. + $this->assertSame(1, $codeigniter->gatherOutputCalls); + } } diff --git a/tests/system/Commands/Cache/ClearCacheTest.php b/tests/system/Commands/Cache/ClearCacheTest.php index a5679b00b3d9..6674fff5a2c8 100644 --- a/tests/system/Commands/Cache/ClearCacheTest.php +++ b/tests/system/Commands/Cache/ClearCacheTest.php @@ -13,11 +13,16 @@ namespace CodeIgniter\Commands\Cache; +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Autoloader\FileLocatorCached; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\CLI\CLI; +use CodeIgniter\Config\Factories; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use Config\Cache; use Config\Services; use PHPUnit\Framework\Attributes\Group; @@ -29,6 +34,8 @@ final class ClearCacheTest extends CIUnitTestCase { use StreamFilterTrait; + private Cache $config; + protected function setUp(): void { parent::setUp(); @@ -36,16 +43,36 @@ protected function setUp(): void CLI::reset(); $this->resetServices(); + $this->config = new Cache(); + $this->config->file['storePath'] = rtrim($this->config->file['storePath'], DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'FileHandlerCommands'; + + if (! is_dir($this->config->file['storePath'])) { + mkdir($this->config->file['storePath'], 0777, true); + } + + Factories::injectMock('config', Cache::class, $this->config); + // Make sure we are testing with the correct handler (override injections) - Services::injectMock('cache', CacheFactory::getHandler(config('Cache'))); + $handler = CacheFactory::getHandler($this->config); + $handler->clean(); + Services::injectMock('cache', $handler); } protected function tearDown(): void { - parent::tearDown(); + $this->config->handler = 'file'; + + if (is_dir($this->config->file['storePath'])) { + CacheFactory::getHandler($this->config)->clean(); + rmdir($this->config->file['storePath']); + } CLI::reset(); + $this->resetFactories(); $this->resetServices(); + + parent::tearDown(); } public function testClearCacheInvalidHandler(): void @@ -69,10 +96,33 @@ public function testClearCacheWorks(): void $this->assertStringContainsString('Cache cleared.', $this->getStreamFilterBuffer()); } + public function testClearCacheDiscardsSharedLocatorCache(): void + { + $handler = new FileVarExportHandler(); + $handler->delete('FileLocatorCache'); + + $locator = new FileLocatorCached(new FileLocator(service('autoloader')), $handler); + $locator->search('Config/App'); + Services::injectMock('locator', $locator); + + command('cache:clear'); + + $locator->search('Config/Cache'); + $locator->__destruct(); + Services::resetSingle('locator'); + + $cached = $handler->get('FileLocatorCache'); + + $this->assertArrayNotHasKey('Config/App', $cached['search']); + $this->assertArrayHasKey('Config/Cache', $cached['search']); + + $handler->delete('FileLocatorCache'); + } + public function testClearCacheFails(): void { $cache = $this->getMockBuilder(FileHandler::class) - ->setConstructorArgs([config('Cache')]) + ->setConstructorArgs([$this->config]) ->onlyMethods(['clean']) ->getMock(); $cache->expects($this->once())->method('clean')->willReturn(false); diff --git a/tests/system/Commands/Cache/InfoCacheTest.php b/tests/system/Commands/Cache/InfoCacheTest.php index 44aa389338cc..0898df6f5ffd 100644 --- a/tests/system/Commands/Cache/InfoCacheTest.php +++ b/tests/system/Commands/Cache/InfoCacheTest.php @@ -14,8 +14,10 @@ namespace CodeIgniter\Commands\Cache; use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Config\Factories; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use Config\Cache; use Config\Services; use PHPUnit\Framework\Attributes\Group; @@ -27,18 +29,41 @@ final class InfoCacheTest extends CIUnitTestCase { use StreamFilterTrait; + private Cache $config; + protected function setUp(): void { parent::setUp(); + $this->config = new Cache(); + $this->config->file['storePath'] = rtrim($this->config->file['storePath'], DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'FileHandlerCommands'; + + if (! is_dir($this->config->file['storePath'])) { + mkdir($this->config->file['storePath'], 0777, true); + } + + Factories::injectMock('config', Cache::class, $this->config); + // Make sure we are testing with the correct handler (override injections) - Services::injectMock('cache', CacheFactory::getHandler(config('Cache'))); + $handler = CacheFactory::getHandler($this->config); + $handler->clean(); + Services::injectMock('cache', $handler); } protected function tearDown(): void { - // restore default cache handler - config('Cache')->handler = 'file'; + $this->config->handler = 'file'; + + if (is_dir($this->config->file['storePath'])) { + CacheFactory::getHandler($this->config)->clean(); + rmdir($this->config->file['storePath']); + } + + $this->resetFactories(); + $this->resetServices(); + + parent::tearDown(); } protected function getBuffer(): string @@ -48,7 +73,7 @@ protected function getBuffer(): string public function testInfoCacheErrorsOnInvalidHandler(): void { - config('Cache')->handler = 'redis'; + $this->config->handler = 'redis'; cache()->save('foo', 'bar'); command('cache:info'); diff --git a/tests/system/Commands/Generators/CellGeneratorTest.php b/tests/system/Commands/Generators/CellGeneratorTest.php index 3412246966cd..10247a34538d 100644 --- a/tests/system/Commands/Generators/CellGeneratorTest.php +++ b/tests/system/Commands/Generators/CellGeneratorTest.php @@ -45,7 +45,7 @@ protected function getFileContents(string $filepath): string return ''; } - return file_get_contents($filepath) ?: ''; + return (string) file_get_contents($filepath); } public function testGenerateCell(): void diff --git a/tests/system/Commands/Generators/CommandGeneratorTest.php b/tests/system/Commands/Generators/CommandGeneratorTest.php index ee94fa591474..83b5cf5407c1 100644 --- a/tests/system/Commands/Generators/CommandGeneratorTest.php +++ b/tests/system/Commands/Generators/CommandGeneratorTest.php @@ -51,7 +51,7 @@ protected function getFileContents(string $filepath): string return ''; } - return file_get_contents($filepath) ?: ''; + return (string) file_get_contents($filepath); } public function testGenerateCommand(): void diff --git a/tests/system/Commands/Generators/ControllerGeneratorTest.php b/tests/system/Commands/Generators/ControllerGeneratorTest.php index e1c77bcb6d5d..5382c05e70f4 100644 --- a/tests/system/Commands/Generators/ControllerGeneratorTest.php +++ b/tests/system/Commands/Generators/ControllerGeneratorTest.php @@ -40,7 +40,7 @@ protected function getFileContents(string $filepath): string return ''; } - return file_get_contents($filepath) ?: ''; + return (string) file_get_contents($filepath); } public function testGenerateController(): void diff --git a/tests/system/Commands/Generators/MigrationGeneratorTest.php b/tests/system/Commands/Generators/MigrationGeneratorTest.php index c4670019a422..7750a2ed35d7 100644 --- a/tests/system/Commands/Generators/MigrationGeneratorTest.php +++ b/tests/system/Commands/Generators/MigrationGeneratorTest.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Commands\Generators; +use CodeIgniter\Config\Factories; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use Config\Database; use PHPUnit\Framework\Attributes\Group; /** @@ -27,6 +29,8 @@ final class MigrationGeneratorTest extends CIUnitTestCase protected function tearDown(): void { + Factories::reset('config'); + $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)) { @@ -52,6 +56,29 @@ public function testGenerateMigrationWithOptionTable(): void $this->assertStringContainsString('_CreateLoggerTable.php', $this->getStreamFilterBuffer()); } + public function testSessionRejectsUnsupportedDriver(): void + { + $config = new Database(); + $config->default['DBDriver'] = 'SQLite3'; + Factories::injectMock('config', 'Database', $config); + + command('make:migration -session'); + + $this->assertStringContainsString( + 'Database sessions are only supported on MySQLi and Postgre. The "default" database group uses the "SQLite3" driver.', + $this->getStreamFilterBuffer(), + ); + $this->assertSame([], glob(APPPATH . 'Database/Migrations/*_CreateCiSessionsTable.php')); + } + + public function testSessionRejectsUndefinedGroup(): void + { + command('make:migration -session -dbgroup bogus'); + + $this->assertStringContainsString('The "bogus" database group is not defined.', $this->getStreamFilterBuffer()); + $this->assertSame([], glob(APPPATH . 'Database/Migrations/*_CreateCiSessionsTable.php')); + } + public function testGenerateMigrationWithOptionSuffix(): void { command('make:migration database -suffix'); diff --git a/tests/system/Commands/Generators/ModelGeneratorTest.php b/tests/system/Commands/Generators/ModelGeneratorTest.php index 8dab8b007f19..bdcfdeae7553 100644 --- a/tests/system/Commands/Generators/ModelGeneratorTest.php +++ b/tests/system/Commands/Generators/ModelGeneratorTest.php @@ -43,7 +43,7 @@ private function getFileContent(string $filepath): string return ''; } - return file_get_contents($filepath) ?: ''; + return (string) file_get_contents($filepath); } public function testGenerateModel(): void diff --git a/tests/system/Commands/Generators/ScaffoldGeneratorTest.php b/tests/system/Commands/Generators/ScaffoldGeneratorTest.php index 7f12e6b93450..6ea74bb3c18f 100644 --- a/tests/system/Commands/Generators/ScaffoldGeneratorTest.php +++ b/tests/system/Commands/Generators/ScaffoldGeneratorTest.php @@ -73,7 +73,7 @@ protected function getFileContents(string $filepath): string return ''; } - return file_get_contents($filepath) ?: ''; + return (string) file_get_contents($filepath); } public function testCreateComponentProducesManyFiles(): void diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php index bfbd00d8bf83..f8daf59ee151 100644 --- a/tests/system/Commands/Translation/LocalizationFinderTest.php +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -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 + */ 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/Utilities/OptimizeTest.php b/tests/system/Commands/Utilities/OptimizeTest.php new file mode 100644 index 000000000000..ccaf9403c613 --- /dev/null +++ b/tests/system/Commands/Utilities/OptimizeTest.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\Utilities; + +use Closure; +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Autoloader\FileLocatorCached; +use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\ReflectionHelper; +use CodeIgniter\Test\StreamFilterTrait; +use Config\Services; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class OptimizeTest extends CIUnitTestCase +{ + use ReflectionHelper; + use StreamFilterTrait; + + private FileVarExportHandler $handler; + + protected function setUp(): void + { + parent::setUp(); + + $this->handler = new FileVarExportHandler(); + $this->handler->delete('FileLocatorCache'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->handler->delete('FileLocatorCache'); + Services::resetSingle('locator'); + } + + /** + * @return Closure(): void + */ + private function getClearCache(): Closure + { + return self::getPrivateMethodInvoker(new Optimize(service('logger'), service('commands')), 'clearCache'); + } + + public function testClearCacheDiscardsSharedLocatorCache(): void + { + $locator = new FileLocatorCached(new FileLocator(service('autoloader')), $this->handler); + $locator->search('Config/App'); + Services::injectMock('locator', $locator); + + ($this->getClearCache())(); + + $locator->search('Config/Cache'); + $locator->__destruct(); + + $cached = $this->handler->get('FileLocatorCache'); + + $this->assertArrayNotHasKey('Config/App', $cached['search']); + $this->assertArrayHasKey('Config/Cache', $cached['search']); + $this->assertStringContainsString('Removed FileLocatorCache.', $this->getStreamFilterBuffer()); + } + + public function testClearCacheDeletesTheFileWithoutSharedLocatorCache(): void + { + $this->handler->save('FileLocatorCache', ['search' => []]); + + ($this->getClearCache())(); + + $this->assertFalse($this->handler->get('FileLocatorCache')); + $this->assertStringContainsString('Removed FileLocatorCache.', $this->getStreamFilterBuffer()); + } +} diff --git a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php index 3413d1a83b86..e3e7369ad131 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php @@ -31,6 +31,9 @@ protected function setUp(): void $this->resetServices(true); } + /** + * @param array>> $filterConfigFilters + */ private function createAutoRouteCollector(array $filterConfigFilters): AutoRouteCollector { $routes = service('routes'); diff --git a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/Dash_folder/Dash_controller.php b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/Dash_folder/Dash_controller.php index 486c8529d082..2a1502c6d12c 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/Dash_folder/Dash_controller.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/Dash_folder/Dash_controller.php @@ -17,11 +17,11 @@ class Dash_controller extends Controller { - public function getSomemethod($p1 = ''): void + public function getSomemethod(string $p1 = ''): void { } - public function getDash_method($p1, $p2 = ''): void + public function getDash_method(string $p1, string $p2 = ''): void { } } diff --git a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/SubDir/BlogController.php b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/SubDir/BlogController.php index fee7eab80416..5054ecd8e861 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/SubDir/BlogController.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/Controllers/SubDir/BlogController.php @@ -21,7 +21,7 @@ public function getIndex(): void { } - public function getSomeMethod($first = ''): void + public function getSomeMethod(string $first = ''): void { } } diff --git a/tests/system/Commands/Utilities/Routes/FilterFinderTest.php b/tests/system/Commands/Utilities/Routes/FilterFinderTest.php index ce813ec3631e..80f566feab75 100644 --- a/tests/system/Commands/Utilities/Routes/FilterFinderTest.php +++ b/tests/system/Commands/Utilities/Routes/FilterFinderTest.php @@ -47,13 +47,19 @@ protected function setUp(): void { parent::setUp(); - $this->request = service('request'); - $this->response = service('response'); + $this->request = service('request'); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $this->moduleConfig = new Modules(); $this->moduleConfig->enabled = false; } + /** + * @param array $routes + */ private function createRouteCollection(array $routes = []): RouteCollectionInterface { $collection = new RouteCollection(service('locator'), $this->moduleConfig, new Routing()); @@ -72,6 +78,9 @@ private function createRouter(RouteCollectionInterface $collection): Router return new Router($collection, $this->request); } + /** + * @param array $config + */ private function createFilters(array $config = []): Filters { $config = ($config !== []) ? $config : [ @@ -159,6 +168,7 @@ public function testFindGlobalsAndRouteFilters(): void public function testFindGlobalsAndRouteFiltersWithArguments(): void { + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection(); $collection->get('admin', ' AdminController::index', ['filter' => 'honeypot:arg1,arg2']); $router = $this->createRouter($collection); @@ -177,6 +187,7 @@ public function testFindGlobalsAndRouteFiltersWithArguments(): void public function testFindClassesGlobalsAndRouteFiltersWithArguments(): void { + /** @var RouteCollection $collection */ $collection = $this->createRouteCollection(); $collection->get('admin', ' AdminController::index', ['filter' => 'honeypot:arg1,arg2']); $router = $this->createRouter($collection); diff --git a/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php b/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php index 1d67afae1ce4..b2c976788b66 100644 --- a/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php +++ b/tests/system/Commands/Utilities/Routes/SampleURIGeneratorTest.php @@ -33,6 +33,9 @@ public function testGet(string $routeKey, string $expected): void $this->assertSame($expected, $uri); } + /** + * @return iterable + */ public static function provideGet(): iterable { yield from [ diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 83750c220bc4..f1ce25c04aa1 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -743,6 +743,9 @@ public function testCleanPathActuallyCleaningThePaths($input, $expected): void $this->assertSame($expected, clean_path($input)); } + /** + * @return iterable + */ public static function provideCleanPathActuallyCleaningThePaths(): iterable { $ds = DIRECTORY_SEPARATOR; @@ -773,7 +776,6 @@ public static function provideCleanPathActuallyCleaningThePaths(): iterable public function testIsCli(): void { - $this->assertIsBool(is_cli()); $this->assertTrue(is_cli()); } @@ -861,7 +863,7 @@ public function testIsWindowsUsingMock(): void $this->assertFalse(is_windows()); $this->assertNotTrue(is_windows()); - is_windows(); + is_windows(null); $this->assertSame(str_contains(php_uname(), 'Windows'), is_windows()); $this->assertSame(defined('PHP_WINDOWS_VERSION_MAJOR'), is_windows()); } diff --git a/tests/system/CommonHelperTest.php b/tests/system/CommonHelperTest.php index 71d5bdb8c14e..641f56dc8ec4 100644 --- a/tests/system/CommonHelperTest.php +++ b/tests/system/CommonHelperTest.php @@ -31,6 +31,9 @@ #[Group('Others')] final class CommonHelperTest extends CIUnitTestCase { + /** + * @var list + */ private array $dummyHelpers = [ APPPATH . 'Helpers' . DIRECTORY_SEPARATOR . 'foobarbaz_helper.php', SYSTEMPATH . 'Helpers' . DIRECTORY_SEPARATOR . 'foobarbaz_helper.php', diff --git a/tests/system/Config/DotEnvTest.php b/tests/system/Config/DotEnvTest.php index c789169b1b87..732fbeac1cdc 100644 --- a/tests/system/Config/DotEnvTest.php +++ b/tests/system/Config/DotEnvTest.php @@ -73,6 +73,9 @@ public function testLoadsVars(string $expected, string $varname): void $this->assertSame($expected, getenv($varname)); } + /** + * @return iterable + */ public static function provideLoadsVars(): iterable { yield from [ @@ -143,6 +146,68 @@ public function testLoadsUnreadableFile(): void $dotenv->load(); } + /** + * Regression test: a concurrent process may remove or replace the .env + * file between the is_file() check and the read attempt. In that case the + * file must be treated as absent (parse() returns null) instead of + * throwing an exception. + */ + public function testParseReturnsNullIfFileRemovedBetweenCheckAndRead(): void + { + $scheme = 'vanishenv'; + + $wrapper = new class () { + public static bool $vanished = false; + + /** + * @var resource|null + */ + public $context; + + /** + * @return array|false + */ + public function url_stat(string $path, int $flags): array|false + { + if (self::$vanished) { + return false; + } + + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0100644, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 1, + 'atime' => 1, + 'mtime' => 1, + 'ctime' => 1, + 'blksize' => 4096, + 'blocks' => 8, + ]; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + self::$vanished = true; + + return false; + } + }; + + stream_wrapper_register($scheme, $wrapper::class, STREAM_IS_URL); + + try { + $dotenv = new DotEnv("{$scheme}://dir", '.env'); + $this->assertNull($dotenv->parse()); + } finally { + stream_wrapper_unregister($scheme); + } + } + public function testQuotedDotenvLoadsEnvironmentVars(): void { $dotenv = new DotEnv($this->fixturesFolder, 'quoted.env'); diff --git a/tests/system/Config/FactoriesTest.php b/tests/system/Config/FactoriesTest.php index 37077c41b1a5..abaef3997a88 100644 --- a/tests/system/Config/FactoriesTest.php +++ b/tests/system/Config/FactoriesTest.php @@ -41,7 +41,7 @@ protected function setUp(): void Factories::reset(); } - protected function getFactoriesStaticProperty(...$params): mixed + protected function getFactoriesStaticProperty(string ...$params): mixed { // First parameter is the actual property $name = array_shift($params); @@ -87,7 +87,10 @@ public function testUsesConfigOptions(): void { // Simulate having a $widgets property in App\Config\Factory $config = new class () extends Factory { - public $widgets = ['bar' => 'bam']; + /** + * @var array + */ + public array $widgets = ['bar' => 'bam']; }; Factories::injectMock('config', Factory::class, $config); @@ -155,14 +158,14 @@ public function testGetsBasenameInvalid(): void public function testCreatesByBasename(): void { - $result = Factories::widgets('SomeWidget', ['getShared' => false]); + $result = Factories::widgets('SomeWidget', ['getShared' => false]); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(SomeWidget::class, $result); } public function testCreatesByClassname(): void { - $result = Factories::widgets(SomeWidget::class, ['getShared' => false]); + $result = Factories::widgets(SomeWidget::class, ['getShared' => false]); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(SomeWidget::class, $result); } @@ -176,22 +179,22 @@ public function testCreatesByAbsoluteClassname(): void public function testCreatesInvalid(): void { - $result = Factories::widgets('gfnusvjai', ['getShared' => false]); + $result = Factories::widgets('gfnusvjai', ['getShared' => false]); // @phpstan-ignore staticMethod.notFound $this->assertNull($result); } public function testIgnoresNonClass(): void { - $result = Factories::widgets('NopeWidget', ['getShared' => false]); + $result = Factories::widgets('NopeWidget', ['getShared' => false]); // @phpstan-ignore staticMethod.notFound $this->assertNull($result); } public function testReturnsSharedInstance(): void { - $widget1 = Factories::widgets('SomeWidget'); - $widget2 = Factories::widgets(SomeWidget::class); + $widget1 = Factories::widgets('SomeWidget'); // @phpstan-ignore staticMethod.notFound + $widget2 = Factories::widgets(SomeWidget::class); // @phpstan-ignore staticMethod.notFound $this->assertSame($widget1, $widget2); } @@ -200,7 +203,7 @@ public function testInjection(): void { Factories::injectMock('widgets', 'Banana', new stdClass()); - $result = Factories::widgets('Banana'); + $result = Factories::widgets('Banana'); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf('stdClass', $result); } @@ -209,7 +212,7 @@ public function testRespectsComponentAlias(): void { Factories::setOptions('tedwigs', ['component' => 'widgets']); - $result = Factories::tedwigs('SomeWidget'); + $result = Factories::tedwigs('SomeWidget'); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(SomeWidget::class, $result); } @@ -225,10 +228,10 @@ public function testRespectsInstanceOf(): void { Factories::setOptions('widgets', ['instanceOf' => 'stdClass']); - $result = Factories::widgets('SomeWidget'); + $result = Factories::widgets('SomeWidget'); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(SomeWidget::class, $result); - $result = Factories::widgets('OtherWidget'); + $result = Factories::widgets('OtherWidget'); // @phpstan-ignore staticMethod.notFound $this->assertNull($result); } @@ -236,7 +239,7 @@ public function testSharedRespectsInstanceOf(): void { Factories::injectMock('widgets', 'SomeWidget', new OtherWidget()); - $result = Factories::widgets('SomeWidget', ['instanceOf' => 'stdClass']); + $result = Factories::widgets('SomeWidget', ['instanceOf' => 'stdClass']); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(SomeWidget::class, $result); } @@ -244,7 +247,7 @@ public function testPrioritizesParameterOptions(): void { Factories::setOptions('widgets', ['instanceOf' => 'stdClass']); - $result = Factories::widgets(OtherWidget::class, ['instanceOf' => null]); + $result = Factories::widgets(OtherWidget::class, ['instanceOf' => null]); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(OtherWidget::class, $result); } @@ -256,7 +259,7 @@ public function testFindsAppFirst(): void class_alias(SomeWidget::class, $class); } - $result = Factories::widgets('OtherWidget'); + $result = Factories::widgets('OtherWidget'); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(SomeWidget::class, $result); } @@ -274,6 +277,7 @@ class TestRegistrar $result = Factories::config('TestRegistrar'); + // @phpstan-ignore argument.type (Config\TestRegistrar is created at runtime by this test) $this->assertInstanceOf('Config\TestRegistrar', $result); // Delete the config class in App @@ -314,7 +318,7 @@ public function testPreferAppIsIgnored(): void class_alias(SomeWidget::class, $class); } - $result = Factories::widgets(OtherWidget::class); + $result = Factories::widgets(OtherWidget::class); // @phpstan-ignore staticMethod.notFound $this->assertInstanceOf(OtherWidget::class, $result); } @@ -366,6 +370,7 @@ public function testDefineSameAliasAndSameClassTwice(): void UserModel::class, ); + // @phpstan-ignore codeigniter.modelArgumentType (aliased to Tests\Support\Models\UserModel above) $model = model('CodeIgniter\Shield\Models\UserModel'); $this->assertInstanceOf(UserModel::class, $model); @@ -379,7 +384,7 @@ public function testDefineNonExistentClass(): void Factories::define( 'models', 'CodeIgniter\Shield\Models\UserModel', - 'App\Models\UserModel', + 'App\Models\UserModel', // @phpstan-ignore argument.type (deliberately does not exist) ); } @@ -395,7 +400,7 @@ public function testDefineAfterLoading(): void Factories::define( 'models', UserModel::class, - 'App\Models\UserModel', + 'App\Models\UserModel', // @phpstan-ignore argument.type (deliberately does not exist) ); } @@ -439,7 +444,6 @@ public function testGetComponentInstances(): array $data = Factories::getComponentInstances('config'); - $this->assertIsArray($data); $this->assertArrayHasKey('aliases', $data); $this->assertArrayHasKey('instances', $data); @@ -469,7 +473,6 @@ public function testSetComponentInstances(array $data): array $data = Factories::getComponentInstances('config'); - $this->assertIsArray($data); $this->assertArrayHasKey('aliases', $data); $this->assertArrayHasKey('instances', $data); diff --git a/tests/system/Config/MimesTest.php b/tests/system/Config/MimesTest.php index 0b22a45e5735..af6839c9da98 100644 --- a/tests/system/Config/MimesTest.php +++ b/tests/system/Config/MimesTest.php @@ -30,6 +30,9 @@ public function testGuessExtensionFromType(?string $expected, string $mime): voi $this->assertSame($expected, Mimes::guessExtensionFromType($mime)); } + /** + * @return iterable + */ public static function provideGuessExtensionFromType(): iterable { return [ @@ -62,6 +65,9 @@ public function testGuessTypeFromExtension(?string $expected, string $ext): void $this->assertSame($expected, Mimes::guessTypeFromExtension($ext)); } + /** + * @return iterable + */ public static function provideGuessTypeFromExtension(): iterable { return [ diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 86228dfadd3f..f9b8d8158c39 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -66,6 +66,9 @@ #[Group('SeparateProcess')] final class ServicesTest extends CIUnitTestCase { + /** + * @var array + */ private array $original; #[WithoutErrorHandler] @@ -194,7 +197,8 @@ public function testNewUnsharedLanguage(): void { $actual = Services::language(null, false); $this->assertInstanceOf(Language::class, $actual); - $this->assertSame('en', $actual->getLocale()); + $locale = $actual->getLocale(); + $this->assertSame('en', $locale); Services::language('la', false); $this->assertSame('en', $actual->getLocale()); @@ -459,7 +463,7 @@ public function testParser(): void public function testRedirectResponse(): void { - $result = Services::redirectResponse(); + $result = Services::redirectresponse(); $this->assertInstanceOf(RedirectResponse::class, $result); } diff --git a/tests/system/Config/fixtures/RegistrarConfig.php b/tests/system/Config/fixtures/RegistrarConfig.php index c2ea35927228..f42b8d5fa29c 100644 --- a/tests/system/Config/fixtures/RegistrarConfig.php +++ b/tests/system/Config/fixtures/RegistrarConfig.php @@ -15,8 +15,12 @@ class RegistrarConfig extends BaseConfig { - public $foo = 'bar'; - public $bar = [ + public string $foo = 'bar'; + + /** + * @var list + */ + public array $bar = [ 'baz', ]; } diff --git a/tests/system/Config/fixtures/SimpleConfig.php b/tests/system/Config/fixtures/SimpleConfig.php index 9c34d8561a3c..7f9be5b52dd8 100644 --- a/tests/system/Config/fixtures/SimpleConfig.php +++ b/tests/system/Config/fixtures/SimpleConfig.php @@ -15,45 +15,63 @@ class SimpleConfig extends BaseConfig { - public $QZERO; - public $QZEROSTR; - public $QEMPTYSTR; - public $QFALSE; - public $first = 'foo'; - public $second = 'bar'; - public $FOO; - public $onedeep; - public $default = [ + public ?string $QZERO = null; + public ?string $QZEROSTR = null; + public ?string $QEMPTYSTR = null; + public bool|string|null $QFALSE = null; + public string $first = 'foo'; + public string $second = 'bar'; + public ?string $FOO = null; + public ?string $onedeep = null; + + /** + * @var array + */ + public array $default = [ 'name' => null, ]; - public $simple = [ + + /** + * @var array + */ + public array $simple = [ 'name' => null, ]; // properties for environment override testing - public $alpha = 'one'; - public $bravo = 'two'; - public $charlie = 'three'; - public $delta = 'four'; - public $echo = ''; - public $foxtrot = 'false'; - public $fruit = 'pineapple'; - public $dessert = ''; - public $golf = 18; - public $crew = [ + public string $alpha = 'one'; + public string $bravo = 'two'; + public string $charlie = 'three'; + public string $delta = 'four'; + public string $echo = ''; + public bool|string $foxtrot = 'false'; + public string $fruit = 'pineapple'; + public string $dessert = ''; + public int $golf = 18; + + /** + * @var array + */ + public array $crew = [ 'captain' => 'Kirk', 'science' => 'Spock', 'doctor' => 'Bones', 'comms' => 'Uhuru', ]; - public $shortie; - public $longie; - public $onedeep_value; - public $one_deep = [ + + public ?string $shortie = null; + public ?string $longie = null; + public ?string $onedeep_value = null; + + /** + * @var array + */ + public array $one_deep = [ 'under_deep' => null, ]; - public $float = 12.34; - public $int = 1234; - public $password = 'secret'; - public ?int $size = null; + + public float $float = 12.34; + public int $int = 1234; + public string $password = 'secret'; + public ?int $size = null; } diff --git a/tests/system/ControllerTest.php b/tests/system/ControllerTest.php index 2ca26f9aa15c..ee6d0604ba99 100644 --- a/tests/system/ControllerTest.php +++ b/tests/system/ControllerTest.php @@ -16,7 +16,6 @@ use CodeIgniter\Config\Factories; use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; -use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; @@ -44,7 +43,7 @@ final class ControllerTest extends CIUnitTestCase /** * Current request. */ - private Request $request; + private IncomingRequest $request; /** * Current response. diff --git a/tests/system/Cookie/CookieStoreTest.php b/tests/system/Cookie/CookieStoreTest.php index 60498052ee93..2b8fb9c5032c 100644 --- a/tests/system/Cookie/CookieStoreTest.php +++ b/tests/system/Cookie/CookieStoreTest.php @@ -24,6 +24,18 @@ #[Group('Others')] final class CookieStoreTest extends CIUnitTestCase { + /** + * @var array{ + * prefix: string, + * expires: int, + * path: string, + * domain: string, + * secure: bool, + * httponly: bool, + * samesite: string, + * raw: bool + * } + */ private array $defaults; protected function setUp(): void diff --git a/tests/system/Cookie/CookieTest.php b/tests/system/Cookie/CookieTest.php index 542ff096d0ce..694c9cde6392 100644 --- a/tests/system/Cookie/CookieTest.php +++ b/tests/system/Cookie/CookieTest.php @@ -28,6 +28,18 @@ #[Group('Others')] final class CookieTest extends CIUnitTestCase { + /** + * @var array{ + * prefix: string, + * expires: int, + * path: string, + * domain: string, + * secure: bool, + * httponly: bool, + * samesite: string, + * raw: bool + * } + */ private array $defaults; protected function setUp(): void @@ -99,6 +111,9 @@ public function testConfigPrefix(string $configPrefix, string $optionPrefix, str $this->assertSame($expected, $cookie->getPrefixedName()); } + /** + * @return iterable + */ public static function provideConfigPrefix(): iterable { yield from [ @@ -175,6 +190,9 @@ public function testInvalidExpires($expires): void new Cookie('test', 'value', ['expires' => $expires]); } + /** + * @return iterable + */ public static function provideInvalidExpires(): iterable { $cases = [ @@ -188,6 +206,9 @@ public static function provideInvalidExpires(): iterable } } + /** + * @param array $changed + */ #[DataProvider('provideSetCookieHeaderCreation')] public function testSetCookieHeaderCreation(string $header, array $changed): void { @@ -196,6 +217,9 @@ public function testSetCookieHeaderCreation(string $header, array $changed): voi $this->assertSame(array_merge($cookie, $changed), $cookie); } + /** + * @return iterable}> + */ public static function provideSetCookieHeaderCreation(): iterable { yield 'basic' => [ @@ -317,4 +341,341 @@ public function testCannotUnsetPropertyViaArrayAccess(): void $cookie = new Cookie('cookie', 'monster'); unset($cookie['path']); } + + #[DataProvider('provideValidationOfRawCookieValue')] + public function testValidationOfRawCookieValue(string $value): void + { + $this->expectException(CookieException::class); + new Cookie('test', $value, ['raw' => true]); + } + + /** + * @return iterable + */ + public static function provideValidationOfRawCookieValue(): iterable + { + yield 'comma' => ['value,comma']; + + yield 'semicolon' => ['value;semicolon']; + + yield 'space' => ['value with space']; + + yield 'tab' => ["value\twith_tab"]; + + yield 'carriage return' => ["value\rcarriage"]; + + yield 'newline' => ["value\nnewline"]; + + yield 'vertical tab' => ["value\vvertical_tab"]; + + yield 'form feed' => ["value\fform_feed"]; + + yield 'null byte' => ["value\0null_byte"]; + + yield 'CRLF' => ["value\r\nwith_crlf"]; + } + + #[DataProvider('provideFromHeaderStringValidationOfRawCookieValue')] + public function testFromHeaderStringValidationOfRawCookieValue(string $value): void + { + $this->expectException(CookieException::class); + Cookie::fromHeaderString("test={$value}; Path=/", true); + } + + /** + * @return iterable + */ + public static function provideFromHeaderStringValidationOfRawCookieValue(): iterable + { + foreach (self::provideValidationOfRawCookieValue() as $name => $case) { + if ($name === 'semicolon') { + continue; + } + + yield $name => $case; + } + } + + public function testFromHeaderStringWithRawTrue(): void + { + $cookie = Cookie::fromHeaderString('test=valid_raw_value=123; Path=/', true); + + $this->assertTrue($cookie->isRaw()); + $this->assertSame('valid_raw_value=123', $cookie->getValue()); + } + + public function testFromHeaderStringWithRawFalseDecodesValue(): void + { + $cookie = Cookie::fromHeaderString('test=value%20with%20space; Path=/', false); + + $this->assertFalse($cookie->isRaw()); + $this->assertSame('value with space', $cookie->getValue()); + } + + public function testValidationOfRawCookieValueInWithValue(): void + { + $this->expectException(CookieException::class); + $cookie = new Cookie('test', 'valid_value', ['raw' => true]); + $cookie->withValue("injected\r\nvalue"); + } + + public function testValidationOfRawCookieValueInWithRaw(): void + { + $this->expectException(CookieException::class); + $cookie = new Cookie('test', "injected\r\nvalue", ['raw' => false]); + $cookie->withRaw(true); + } + + public function testValidRawCookieRetainsValueWithoutEncoding(): void + { + $cookie = new Cookie('test', 'valid_raw_value=123', ['raw' => true]); + + $this->assertSame('valid_raw_value=123', $cookie->getValue()); + $this->assertStringContainsString('test=valid_raw_value=123', (string) $cookie); + } + + public function testNonRawCookieSafelyEncodesCRLF(): void + { + $cookie = new Cookie('test', "value\r\nwith_crlf", ['raw' => false]); + $result = (string) $cookie; + + $this->assertStringContainsString('%0D%0A', $result); + $this->assertStringNotContainsString("\r", $result); + $this->assertStringNotContainsString("\n", $result); + } + + #[DataProvider('provideValidationOfCookiePath')] + public function testValidationOfCookiePath(string $path): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookiePath')); + new Cookie('test', 'value', ['path' => $path]); + } + + #[DataProvider('provideValidationOfCookiePath')] + public function testValidationOfCookiePathInWithPath(string $path): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookiePath')); + $cookie = new Cookie('test', 'value'); + $cookie->withPath($path); + } + + /** + * @return iterable + */ + public static function provideValidationOfCookiePath(): iterable + { + yield 'comma' => ['/path,comma']; + + yield 'semicolon' => ['/path;semicolon']; + + yield 'space' => ['/path with space']; + + yield 'tab' => ["/path\twith_tab"]; + + yield 'carriage return' => ["/path\rcarriage"]; + + yield 'newline' => ["/path\nnewline"]; + + yield 'vertical tab' => ["/path\vvertical_tab"]; + + yield 'form feed' => ["/path\fform_feed"]; + + yield 'null byte' => ["/path\0null_byte"]; + + yield 'CRLF' => ["/path\r\nwith_crlf"]; + } + + #[DataProvider('provideFromHeaderStringValidationOfCookiePath')] + public function testFromHeaderStringValidationOfCookiePath(string $path): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookiePath')); + Cookie::fromHeaderString("test=value; Path={$path}"); + } + + /** + * @return iterable + */ + public static function provideFromHeaderStringValidationOfCookiePath(): iterable + { + foreach (self::provideValidationOfCookiePath() as $name => $case) { + if ($name === 'semicolon') { + continue; + } + + yield $name => $case; + } + } + + #[DataProvider('provideValidationOfCookieDomain')] + public function testValidationOfCookieDomain(string $domain): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookieDomain')); + new Cookie('test', 'value', ['domain' => $domain]); + } + + #[DataProvider('provideValidationOfCookieDomain')] + public function testValidationOfCookieDomainInWithDomain(string $domain): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookieDomain')); + $cookie = new Cookie('test', 'value'); + $cookie->withDomain($domain); + } + + /** + * @return iterable + */ + public static function provideValidationOfCookieDomain(): iterable + { + yield 'comma' => ['domain,comma.com']; + + yield 'semicolon' => ['domain;semicolon.com']; + + yield 'space' => ['domain with space.com']; + + yield 'tab' => ["domain\twith_tab.com"]; + + yield 'carriage return' => ["domain\rcarriage.com"]; + + yield 'newline' => ["domain\nnewline.com"]; + + yield 'vertical tab' => ["domain\vvertical_tab.com"]; + + yield 'form feed' => ["domain\fform_feed.com"]; + + yield 'null byte' => ["domain\0null_byte.com"]; + + yield 'CRLF' => ["domain\r\nwith_crlf.com"]; + } + + #[DataProvider('provideFromHeaderStringValidationOfCookieDomain')] + public function testFromHeaderStringValidationOfCookieDomain(string $domain): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookieDomain')); + Cookie::fromHeaderString("test=value; Domain={$domain}"); + } + + /** + * @return iterable + */ + public static function provideFromHeaderStringValidationOfCookieDomain(): iterable + { + foreach (self::provideValidationOfCookieDomain() as $name => $case) { + if ($name === 'semicolon') { + continue; + } + + yield $name => $case; + } + } + + public function testNullPathAndDomainDefaultProperly(): void + { + $cookie = new Cookie('test', 'val', ['path' => null, 'domain' => null, 'prefix' => null]); + + $this->assertSame('/', $cookie->getPath()); + $this->assertSame('', $cookie->getDomain()); + $this->assertSame('', $cookie->getPrefix()); + + $cookie2 = $cookie->withPath(null)->withDomain(null)->withPrefix(''); + $this->assertSame('/', $cookie2->getPath()); + $this->assertSame('', $cookie2->getDomain()); + $this->assertSame('', $cookie2->getPrefix()); + } + + public function testValidCookiePathAndDomain(): void + { + $cookie = new Cookie('test', 'val', ['path' => '/sub/dir/', 'domain' => 'example.com']); + $this->assertSame('/sub/dir/', $cookie->getPath()); + $this->assertSame('example.com', $cookie->getDomain()); + + $cookie2 = $cookie->withPath('/another/path')->withDomain('.example.com'); + $this->assertSame('/another/path', $cookie2->getPath()); + $this->assertSame('.example.com', $cookie2->getDomain()); + + $cookie3 = new Cookie('test', 'val', ['path' => '/', 'domain' => '']); + $this->assertSame('/', $cookie3->getPath()); + $this->assertSame('', $cookie3->getDomain()); + } + + #[DataProvider('provideValidationOfCookiePrefix')] + public function testValidationOfCookiePrefix(string $prefix): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix])); + new Cookie('test', 'val', ['prefix' => $prefix]); + } + + #[DataProvider('provideValidationOfCookiePrefix')] + public function testValidationOfCookiePrefixInWithPrefix(string $prefix): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix])); + $cookie = new Cookie('test', 'val'); + $cookie->withPrefix($prefix); + } + + #[DataProvider('provideValidationOfCookiePrefix')] + public function testValidationOfRawCookiePrefix(string $prefix): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix])); + new Cookie('test', 'val', ['prefix' => $prefix, 'raw' => true]); + } + + #[DataProvider('provideValidationOfCookiePrefix')] + public function testValidationOfRawCookiePrefixInWithPrefix(string $prefix): void + { + $this->expectException(CookieException::class); + $this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix])); + $cookie = new Cookie('test', 'val', ['raw' => true]); + $cookie->withPrefix($prefix); + } + + /** + * @return iterable + */ + public static function provideValidationOfCookiePrefix(): iterable + { + yield 'equals' => ['prefix=']; + + yield 'comma' => ['prefix,']; + + yield 'semicolon' => ['prefix;']; + + yield 'space' => ['prefix ']; + + yield 'tab' => ["prefix\t"]; + + yield 'carriage return' => ["prefix\r"]; + + yield 'newline' => ["prefix\n"]; + + yield 'vertical tab' => ["prefix\v"]; + + yield 'form feed' => ["prefix\f"]; + + yield 'null byte' => ["prefix\0"]; + + yield 'CRLF' => ["prefix\r\n"]; + } + + public function testValidCookiePrefixAllowedSeparators(): void + { + $cookie = new Cookie('test', 'val', ['prefix' => 'ci:session/']); + $this->assertSame('ci:session/', $cookie->getPrefix()); + $this->assertSame('ci:session/test', $cookie->getPrefixedName()); + + $cookie2 = $cookie->withPrefix('my-app:v1/'); + $this->assertSame('my-app:v1/', $cookie2->getPrefix()); + $this->assertSame('my-app:v1/test', $cookie2->getPrefixedName()); + + $cookie3 = new Cookie('test', 'val', ['prefix' => 'ci:session/', 'raw' => false]); + $this->assertSame('ci:session/test=val; Path=/; HttpOnly; SameSite=Lax', $cookie3->toHeaderString()); + } } diff --git a/tests/system/DataConverter/DataConverterTest.php b/tests/system/DataConverter/DataConverterTest.php index 6686618a181b..4ee4a8078217 100644 --- a/tests/system/DataConverter/DataConverterTest.php +++ b/tests/system/DataConverter/DataConverterTest.php @@ -44,6 +44,11 @@ public function testInstantiate(): void $this->assertInstanceOf(DataConverter::class, $converter); } + /** + * @param array $types + * @param array $dbData + * @param array $expected + */ #[DataProvider('provideConvertDataFromDB')] public function testConvertDataFromDB(array $types, array $dbData, array $expected): void { @@ -54,6 +59,9 @@ public function testConvertDataFromDB(array $types, array $dbData, array $expect $this->assertSame($expected, $data); } + /** + * @return iterable, array, array}> + */ public static function provideConvertDataFromDB(): iterable { yield from [ @@ -270,6 +278,11 @@ public static function provideConvertDataFromDB(): iterable ]; } + /** + * @param array $types + * @param array $phpData + * @param array $expected + */ #[DataProvider('provideConvertDataToDB')] public function testConvertDataToDB(array $types, array $phpData, array $expected): void { @@ -280,6 +293,9 @@ public function testConvertDataToDB(array $types, array $phpData, array $expecte $this->assertSame($expected, $data); } + /** + * @return iterable, array, array}> + */ public static function provideConvertDataToDB(): iterable { yield from [ @@ -706,6 +722,8 @@ public function testNotNullable(): void } /** + * @param array $types + * @param array $handlers * @param (Closure(array): object)|string|null $reconstructor * @param (Closure(object, bool, bool): array)|string|null $extractor */ diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index 4d6d1f76173d..7052250eab8c 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -27,6 +27,9 @@ #[Group('Others')] final class BaseConnectionTest extends CIUnitTestCase { + /** + * @var array + */ private array $options = [ 'DSN' => '', 'hostname' => 'localhost', @@ -50,6 +53,10 @@ final class BaseConnectionTest extends CIUnitTestCase 'time' => 'H:i:s', ], ]; + + /** + * @var array + */ private array $failoverOptions = [ 'DSN' => '', 'hostname' => 'localhost', @@ -337,6 +344,9 @@ public function testProtectIdentifiers( $this->assertSame($expected, $return); } + /** + * @return iterable + */ public static function provideProtectIdentifiers(): iterable { yield from [ @@ -362,6 +372,11 @@ public static function provideProtectIdentifiers(): iterable 'quoted table alias' => [false, true, false, '"jobs" "j"', '"jobs" "j"'], 'quoted table alias prefix' => [true, true, false, '"jobs" "j"', '"test_jobs" "j"'], + 'schema.table' => [false, true, false, 'tenant.jobs', '"tenant"."test_jobs"'], + 'schema.table prefix' => [true, true, false, 'tenant.jobs', '"tenant"."test_jobs"'], + 'quoted schema.table prefix' => [true, true, false, '"tenant"."test_jobs"', '"tenant"."test_jobs"'], + 'quoted schema.table no-protect' => [true, false, false, '"tenant"."test_jobs"', 'tenant.test_jobs'], + 'table.*' => [false, true, true, 'jobs.*', '"test_jobs".*'], // Prefixed because it has segments 'table.* prefix' => [true, true, true, 'jobs.*', '"test_jobs".*'], 'table.column' => [false, true, true, 'users.id', '"test_users"."id"'], // Prefixed because it has segments @@ -424,6 +439,27 @@ public static function provideProtectIdentifiers(): iterable ]; } + public function testProtectIdentifiersWithBracketEscapeChar(): void + { + $db = new MockConnection($this->options); + $db->escapeChar = ['[', ']']; + + $this->assertSame('[test_jobs]', $db->protectIdentifiers('jobs', true, true, false)); + $this->assertSame('[test_jobs]', $db->protectIdentifiers('[test_jobs]', true, true, false)); + $this->assertSame('test_jobs', $db->protectIdentifiers('[test_jobs]', true, false, false)); + $this->assertSame('[test_jobs].[id]', $db->protectIdentifiers('jobs.id')); + $this->assertSame('[test_jobs].[id]', $db->protectIdentifiers('[test_jobs].[id]')); + $this->assertSame('test_jobs.id', $db->protectIdentifiers('[test_jobs].[id]', true, false)); + } + + public function testProtectIdentifiersStripsQuotesFromDottedItemWithoutPrefix(): void + { + $db = new MockConnection([...$this->options, 'DBPrefix' => '']); + + $this->assertSame('public.jobs', $db->protectIdentifiers('"public"."jobs"', true, false, false)); + $this->assertSame('"public"."jobs"', $db->protectIdentifiers('"public"."jobs"', true, true, false)); + } + /** * These tests are intended to confirm the current behavior. */ diff --git a/tests/system/Database/BaseQueryTest.php b/tests/system/Database/BaseQueryTest.php index c5265b85dfa7..b50e41b7ece8 100644 --- a/tests/system/Database/BaseQueryTest.php +++ b/tests/system/Database/BaseQueryTest.php @@ -107,12 +107,8 @@ public function testSwapPrefix(): void $this->assertSame($newSQL, $query->getQuery()); } - /** - * @param mixed $expected - * @param mixed $sql - */ #[DataProvider('provideIsWriteType')] - public function testIsWriteType($expected, $sql): void + public function testIsWriteType(bool $expected, string $sql): void { $query = new Query($this->db); @@ -120,6 +116,9 @@ public function testIsWriteType($expected, $sql): void $this->assertSame($expected, $query->isWriteType()); } + /** + * @return iterable + */ public static function provideIsWriteType(): iterable { return [ @@ -579,12 +578,8 @@ public function testSwapPrefixAfterGetQuery(): void $this->assertSame($expected, $query->getQuery()); } - /** - * @param mixed $expected - * @param mixed $sql - */ #[DataProvider('provideHighlightQueryKeywords')] - public function testHighlightQueryKeywords($expected, $sql): void + public function testHighlightQueryKeywords(string $expected, string $sql): void { $query = new Query($this->db); $query->setQuery($sql); @@ -592,6 +587,9 @@ public function testHighlightQueryKeywords($expected, $sql): void $this->assertSame($expected, $query->debugToolbarDisplay()); } + /** + * @return iterable + */ public static function provideHighlightQueryKeywords(): iterable { return [ diff --git a/tests/system/Database/BaseResultTest.php b/tests/system/Database/BaseResultTest.php index 901faee6a565..aa14f54ec8fb 100644 --- a/tests/system/Database/BaseResultTest.php +++ b/tests/system/Database/BaseResultTest.php @@ -67,7 +67,7 @@ public function getFieldNames(): array } /** - * @return list + * @return list */ public function getFieldData(): array { @@ -84,7 +84,7 @@ public function dataSeek(int $n = 0): bool } /** - * @return false|list>|null + * @return array|false|null */ protected function fetchAssoc(): array|bool|null { diff --git a/tests/system/Database/Builder/DeleteTest.php b/tests/system/Database/Builder/DeleteTest.php index 200eb9ca5b4a..0866cac724a4 100644 --- a/tests/system/Database/Builder/DeleteTest.php +++ b/tests/system/Database/Builder/DeleteTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Postgre\Builder as PostgreBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use CodeIgniter\Test\Mock\MockQuery; @@ -169,4 +170,50 @@ public function testDeleteBatchEscapesWhereInBinds(): void $this->assertStringContainsString("IN ('anything'' OR ''1''=''1','Ahmadinejad')", $query->getQuery()); $this->assertStringNotContainsString("IN anything' OR '1'='1", $query->getQuery()); } + + public function testDeleteBatchPostgreCastsPositionalAndMappedConstraints(): void + { + $db = new class (['DBDriver' => 'Postgre']) extends MockConnection { + protected function _fieldData(string $table): array + { + return [ + (object) ['name' => 'id', 'type' => 'integer', 'max_length' => 32], + (object) ['name' => 'name', 'type' => 'text', 'max_length' => null], + ]; + } + }; + + $positional = (new PostgreBuilder('jobs', $db))->testMode() + ->setData([['id' => 1], ['id' => '2'], ['id' => 1.4]], null, 'data') + ->onConstraint('id') + ->deleteBatch(); + + $expected = <<<'EOF' + DELETE FROM "jobs" + USING ( + SELECT 1 "id" UNION ALL + SELECT CAST('2' AS INTEGER) "id" UNION ALL + SELECT 1.4 "id" + ) "data" + WHERE "jobs"."id" = "data"."id" + EOF; + + $this->assertSame($expected, rtrim($positional[0])); + + $mapped = (new PostgreBuilder('jobs', $db))->testMode() + ->setData([['value' => 'x'], ['value' => 5]], null, 'data') + ->onConstraint(['name' => 'value']) + ->deleteBatch(); + + $expected = <<<'EOF' + DELETE FROM "jobs" + USING ( + SELECT CAST('x' AS TEXT) "value" UNION ALL + SELECT CAST(5 AS TEXT) "value" + ) "data" + WHERE "jobs"."name" = CAST("data"."value" AS TEXT) + EOF; + + $this->assertSame($expected, rtrim($mapped[0])); + } } diff --git a/tests/system/Database/Builder/InsertTest.php b/tests/system/Database/Builder/InsertTest.php index e8761b9de52b..062cf9786b34 100644 --- a/tests/system/Database/Builder/InsertTest.php +++ b/tests/system/Database/Builder/InsertTest.php @@ -27,11 +27,6 @@ #[Group('Others')] final class InsertTest extends CIUnitTestCase { - /** - * @var MockConnection - */ - protected $db; - protected function setUp(): void { parent::setUp(); @@ -162,6 +157,7 @@ public function testInsertBatch(): void ], ]; + $this->assertInstanceOf(MockConnection::class, $this->db); $this->db->shouldReturn('execute', new class () {}); $builder->insertBatch($insertData, true); @@ -197,6 +193,7 @@ public function testInsertBatchIgnore(): void ], ]; + $this->assertInstanceOf(MockConnection::class, $this->db); $this->db->shouldReturn('execute', new class () {}); $builder->ignore()->insertBatch($insertData, true, 1); @@ -231,6 +228,7 @@ public function testInsertBatchWithoutEscape(): void ], ]; + $this->assertInstanceOf(MockConnection::class, $this->db); $this->db->shouldReturn('execute', new class () {}); $builder->insertBatch($insertData, false); @@ -255,6 +253,7 @@ public function testInsertBatchWithFieldsEndingInNumbers(): void ['ip' => '4.4.4.0', 'ip2' => '4.4.4.2'], ]; + $this->assertInstanceOf(MockConnection::class, $this->db); $this->db->shouldReturn('execute', new class () {}); $builder->insertBatch($data, true); diff --git a/tests/system/Database/Builder/UnionTest.php b/tests/system/Database/Builder/UnionTest.php index 5711c6c3cbf7..c6d01273e2e5 100644 --- a/tests/system/Database/Builder/UnionTest.php +++ b/tests/system/Database/Builder/UnionTest.php @@ -25,11 +25,6 @@ #[Group('Others')] final class UnionTest extends CIUnitTestCase { - /** - * @var MockConnection - */ - protected $db; - protected function setUp(): void { parent::setUp(); diff --git a/tests/system/Database/Builder/UpdateTest.php b/tests/system/Database/Builder/UpdateTest.php index 5bb93ad2a598..91b220b0ba1b 100644 --- a/tests/system/Database/Builder/UpdateTest.php +++ b/tests/system/Database/Builder/UpdateTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Postgre\Builder as PostgreBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use CodeIgniter\Test\Mock\MockQuery; @@ -26,11 +27,6 @@ #[Group('Others')] final class UpdateTest extends CIUnitTestCase { - /** - * @var MockConnection - */ - protected $db; - protected function setUp(): void { parent::setUp(); @@ -229,6 +225,7 @@ public function testUpdateBatch(): void ], ]; + $this->assertInstanceOf(MockConnection::class, $this->db); $this->db->shouldReturn('execute', new class () {}); $builder->updateBatch($updateData, 'id'); @@ -268,6 +265,7 @@ public function testSetUpdateBatchWithoutEscape(): void ], ], 'id', $escape); + $this->assertInstanceOf(MockConnection::class, $this->db); $this->db->shouldReturn('execute', new class () {}); $builder->updateBatch(null, 'id'); @@ -472,4 +470,72 @@ public function testSetWithAndWithoutEscape(): void $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledUpdate())); $this->assertSame($expectedBinds, $builder->getBinds()); } + + public function testUpdateBatchPostgreDoesNotCastEnumAndArrayColumns(): void + { + $db = new class (['DBDriver' => 'Postgre']) extends MockConnection { + protected function _fieldData(string $table): array + { + return [ + (object) ['name' => 'id', 'type' => 'integer', 'max_length' => 32], + (object) ['name' => 'mood', 'type' => 'USER-DEFINED', 'max_length' => null], + (object) ['name' => 'tags', 'type' => 'ARRAY', 'max_length' => null], + ]; + } + }; + + $builder = new PostgreBuilder('jobs', $db); + $sql = $builder->testMode()->updateBatch([ + ['id' => 1, 'mood' => 'happy', 'tags' => '{a}'], + ['id' => 2, 'mood' => 'sad', 'tags' => '{b}'], + ], 'id'); + + $expected = <<<'EOF' + UPDATE "jobs" + SET + "mood" = _u."mood", + "tags" = _u."tags" + FROM ( + SELECT 1 "id", 'happy' "mood", '{a}' "tags" UNION ALL + SELECT 2 "id", 'sad' "mood", '{b}' "tags" + ) _u + WHERE "jobs"."id" = CAST(_u."id" AS INTEGER) + EOF; + + $this->assertSame([$expected], $sql); + } + + public function testUpdateBatchPostgreDoesNotCastSourceSharedByColumnsOfDifferentTypes(): void + { + $db = new class (['DBDriver' => 'Postgre']) extends MockConnection { + protected function _fieldData(string $table): array + { + return [ + (object) ['name' => 'id', 'type' => 'integer', 'max_length' => 32], + (object) ['name' => 'number', 'type' => 'integer', 'max_length' => 32], + (object) ['name' => 'label', 'type' => 'text', 'max_length' => null], + ]; + } + }; + + $builder = new PostgreBuilder('jobs', $db); + $sql = $builder->testMode() + ->setData([['id' => 1, 'value' => 1.4]], null, 'data') + ->updateFields(['number' => 'value', 'label' => 'value']) + ->onConstraint('id') + ->updateBatch(); + + $expected = <<<'EOF' + UPDATE "jobs" + SET + "number" = CAST("data"."value" AS INTEGER), + "label" = CAST("data"."value" AS TEXT) + FROM ( + SELECT 1 "id", 1.4 "value" + ) "data" + WHERE "jobs"."id" = CAST("data"."id" AS INTEGER) + EOF; + + $this->assertSame([$expected], $sql); + } } diff --git a/tests/system/Database/Builder/WhenTest.php b/tests/system/Database/Builder/WhenTest.php index 2fc4966accb8..e902d8471fd2 100644 --- a/tests/system/Database/Builder/WhenTest.php +++ b/tests/system/Database/Builder/WhenTest.php @@ -25,11 +25,6 @@ #[Group('Others')] final class WhenTest extends CIUnitTestCase { - /** - * @var MockConnection - */ - protected $db; - protected function setUp(): void { parent::setUp(); diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index 2d0c2a3e1233..65baa097a832 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -30,11 +30,6 @@ #[Group('Others')] final class WhereTest extends CIUnitTestCase { - /** - * @var MockConnection - */ - protected $db; - protected function setUp(): void { parent::setUp(); @@ -407,6 +402,9 @@ public function testWhereInvalidKeyThrowInvalidArgumentException($key): void $builder->whereIn($key, ['Politician', 'Accountant']); } + /** + * @return iterable + */ public static function provideWhereInvalidKeyThrowInvalidArgumentException(): iterable { return [ @@ -427,6 +425,9 @@ public function testWhereInEmptyValuesThrowInvalidArgumentException($values): vo $builder->whereIn('name', $values); } + /** + * @return iterable + */ public static function provideWhereInEmptyValuesThrowInvalidArgumentException(): iterable { return [ diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index 1d2482e0ad87..980404ea9f3d 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -27,6 +27,9 @@ final class ConfigTest extends CIUnitTestCase { use ReflectionHelper; + /** + * @var array + */ private array $group = [ 'DSN' => '', 'hostname' => 'localhost', @@ -46,6 +49,10 @@ final class ConfigTest extends CIUnitTestCase 'failover' => [], 'port' => 3306, ]; + + /** + * @var array + */ private array $dsnGroup = [ 'DSN' => 'MySQLi://user:pass@localhost:3306/dbname?DBPrefix=test_&pConnect=true&charset=latin1&DBCollat=latin1_swedish_ci', 'hostname' => '', @@ -65,6 +72,10 @@ final class ConfigTest extends CIUnitTestCase 'failover' => [], 'port' => 3306, ]; + + /** + * @var array + */ private array $dsnGroupPostgre = [ 'DSN' => 'Postgre://user:pass@localhost:5432/dbname?DBPrefix=test_&connect_timeout=5&sslmode=1', 'hostname' => '', @@ -84,6 +95,10 @@ final class ConfigTest extends CIUnitTestCase 'failover' => [], 'port' => 5432, ]; + + /** + * @var array + */ private array $dsnGroupPostgreNative = [ 'DSN' => 'pgsql:host=localhost;port=5432;dbname=database_name', 'hostname' => '', @@ -214,6 +229,9 @@ public function testConvertDSN(string $input, string $expected): void $this->assertSame($expected, $this->getPrivateProperty($conn, 'DSN')); } + /** + * @return iterable + */ public static function provideConvertDSN(): iterable { yield from [ diff --git a/tests/system/Database/Forge/CreateTableTest.php b/tests/system/Database/Forge/CreateTableTest.php index 115c5506d379..b8932f446777 100644 --- a/tests/system/Database/Forge/CreateTableTest.php +++ b/tests/system/Database/Forge/CreateTableTest.php @@ -42,7 +42,7 @@ public function testCreateTableWithDefaultRawSql(): void ->with($sql) ->willReturn(true); - $forge = new class ($dbMock) extends Forge {}; + $forge = new Forge($dbMock); $fields = [ 'id' => [ diff --git a/tests/system/Database/Live/AbstractGetFieldDataTestCase.php b/tests/system/Database/Live/AbstractGetFieldDataTestCase.php index 4749fabd9de7..533e1089c3d6 100644 --- a/tests/system/Database/Live/AbstractGetFieldDataTestCase.php +++ b/tests/system/Database/Live/AbstractGetFieldDataTestCase.php @@ -57,6 +57,30 @@ protected function tearDown(): void $this->forge->dropTable($this->table, true); } + protected function qualifiedTableName(): string + { + return match ($this->db->DBDriver) { + 'MySQLi' => $this->db->getDatabase() . '.' . $this->table, + 'OCI8' => $this->db->username . '.' . $this->table, + 'Postgre' => 'public.' . $this->table, + 'SQLite3' => 'main.' . $this->table, + 'SQLSRV' => 'dbo.' . $this->table, + default => $this->markTestSkipped('No qualified table name is defined for ' . $this->db->DBDriver . '.'), + }; + } + + public function testGetFieldDataWithQualifiedTableName(): void + { + $this->createTableForDefault(); + + $toArray = static fn (stdClass $field): array => (array) $field; + + $this->assertSame( + array_map($toArray, $this->db->getFieldData($this->table)), + array_map($toArray, $this->db->getFieldData($this->qualifiedTableName())), + ); + } + protected function createTableForDefault(): void { $this->forge->dropTable($this->table, true); diff --git a/tests/system/Database/Live/BadQueryTest.php b/tests/system/Database/Live/BadQueryTest.php index 8b842b21b930..587cabc3bdec 100644 --- a/tests/system/Database/Live/BadQueryTest.php +++ b/tests/system/Database/Live/BadQueryTest.php @@ -53,4 +53,22 @@ public function testBadQueryDebugFalse(): void $this->enableDBDebug(); } + + public function testPostgreBadQueryDebugTrueWithWarningsDisabled(): void + { + if ($this->db->DBDriver !== 'Postgre') { + $this->markTestSkipped('This test is only for Postgre.'); + } + + $this->enableDBDebug(); + $errorReporting = error_reporting(E_ALL & ~E_WARNING); + + try { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('table_does_not_exist'); + $this->db->query('SELECT * FROM table_does_not_exist'); + } finally { + error_reporting($errorReporting); + } + } } diff --git a/tests/system/Database/Live/ConnectTest.php b/tests/system/Database/Live/ConnectTest.php index e41fdbdfc114..e981d3ab33cf 100644 --- a/tests/system/Database/Live/ConnectTest.php +++ b/tests/system/Database/Live/ConnectTest.php @@ -28,9 +28,20 @@ final class ConnectTest extends CIUnitTestCase { use DatabaseTestTrait; - private $group1; - private $group2; - private $tests; + /** + * @var array + */ + private array $group1 = []; + + /** + * @var array + */ + private array $group2 = []; + + /** + * @var array + */ + private array $tests = []; protected function setUp(): void { diff --git a/tests/system/Database/Live/DeleteTest.php b/tests/system/Database/Live/DeleteTest.php index 651dff251a7f..8ef937fb00f5 100644 --- a/tests/system/Database/Live/DeleteTest.php +++ b/tests/system/Database/Live/DeleteTest.php @@ -119,6 +119,73 @@ public function testDeleteBatchPreventsSQLInjectionInWhere(): void $this->seeInDatabase('user', ['email' => 'derek@world.com', 'name' => 'Derek Jones']); } + public function testDeleteBatchWithMixedConstraintValueTypesInTextColumn(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $this->markTestSkipped('SQL Server cannot compare `text` columns with `=`.'); + } + + $table = 'type_test'; + + $builder = $this->db->table($table); + $builder->truncate(); + + foreach (['Example', '587', 'kept'] as $i => $text) { + $builder->insert([ + 'type_varchar' => 'test' . $i, + 'type_char' => 'char', + 'type_text' => $text, + 'type_smallint' => 32767, + 'type_integer' => 2_147_483_647, + 'type_bigint' => 9_223_372_036_854_775_807, + 'type_float' => 10.1, + 'type_numeric' => 123.23, + 'type_date' => '2023-12-0' . ($i + 1), + 'type_datetime' => '2023-12-21 12:00:00', + ]); + } + + $this->db->table($table) + ->setData([['text' => 'Example'], ['text' => 587]], null, 'data') + ->onConstraint(['type_text' => 'text']) + ->deleteBatch(); + + $this->dontSeeInDatabase($table, ['type_varchar' => 'test0']); + $this->dontSeeInDatabase($table, ['type_varchar' => 'test1']); + $this->seeInDatabase($table, ['type_varchar' => 'test2']); + } + + public function testDeleteBatchDoesNotTruncateConstraintValueForCharColumn(): void + { + $table = 'type_test'; + + $builder = $this->db->table($table); + $builder->truncate(); + + for ($i = 0; $i < 2; $i++) { + $builder->insert([ + 'type_varchar' => 'test' . $i, + 'type_char' => 'char' . $i, + 'type_text' => 'text', + 'type_smallint' => 32767, + 'type_integer' => 2_147_483_647, + 'type_bigint' => 9_223_372_036_854_775_807, + 'type_float' => 10.1, + 'type_numeric' => 123.23, + 'type_date' => '2023-12-0' . ($i + 1), + 'type_datetime' => '2023-12-21 12:00:00', + ]); + } + + $this->db->table($table) + ->setData([['char' => 'char0 X'], ['char' => 'char1']], null, 'data') + ->onConstraint(['type_char' => 'char']) + ->deleteBatch(); + + $this->seeInDatabase($table, ['type_varchar' => 'test0']); + $this->dontSeeInDatabase($table, ['type_varchar' => 'test1']); + } + public function testDeleteBatchConstraintsDate(): void { $table = 'type_test'; diff --git a/tests/system/Database/Live/EscapeTest.php b/tests/system/Database/Live/EscapeTest.php index adc5d414e34e..b3c27601ac42 100644 --- a/tests/system/Database/Live/EscapeTest.php +++ b/tests/system/Database/Live/EscapeTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; use CodeIgniter\Database\RawSql; use CodeIgniter\I18n\Time; use CodeIgniter\Test\CIUnitTestCase; @@ -100,6 +101,8 @@ public function testEscapeLikeStringStringable(): void public function testEscapeLikeStringDirect(): void { if ($this->db->DBDriver === 'MySQLi') { + $this->assertInstanceOf(MySQLiConnection::class, $this->db); + $expected = "SHOW COLUMNS FROM brands WHERE column LIKE 'wild\\_chars%'"; $sql = "SHOW COLUMNS FROM brands WHERE column LIKE '" . $this->db->escapeLikeStringDirect('wild_chars') . "%'"; diff --git a/tests/system/Database/Live/FabricatorLiveTest.php b/tests/system/Database/Live/FabricatorLiveTest.php index a865d2fef4c8..2a45b3ce19cc 100644 --- a/tests/system/Database/Live/FabricatorLiveTest.php +++ b/tests/system/Database/Live/FabricatorLiveTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Test\DatabaseTestTrait; use CodeIgniter\Test\Fabricator; use PHPUnit\Framework\Attributes\Group; +use stdClass; use Tests\Support\Models\UserModel; use Tests\Support\Models\ValidModel; @@ -39,6 +40,7 @@ public function testCreateAddsToDatabase(): void $fabricator->setOverrides(['country' => 'Spain']); $result = $fabricator->create(); + $this->assertInstanceOf(stdClass::class, $result); $this->seeInDatabase('user', ['name' => $result->name]); } diff --git a/tests/system/Database/Live/ForgeTest.php b/tests/system/Database/Live/ForgeTest.php index 39433abde857..8c5b80e1c8d7 100644 --- a/tests/system/Database/Live/ForgeTest.php +++ b/tests/system/Database/Live/ForgeTest.php @@ -544,10 +544,10 @@ public function testForeignKey(): void } $this->assertSame($foreignKeyData[$foreignKeyName]->constraint_name, $foreignKeyName); - $this->assertSame($foreignKeyData[$foreignKeyName]->column_name, ['users_id']); - $this->assertSame($foreignKeyData[$foreignKeyName]->foreign_column_name, ['id']); - $this->assertSame($foreignKeyData[$foreignKeyName]->table_name, $this->db->DBPrefix . $tableName); - $this->assertSame($foreignKeyData[$foreignKeyName]->foreign_table_name, $this->db->DBPrefix . 'forge_test_users'); + $this->assertSame(['users_id'], $foreignKeyData[$foreignKeyName]->column_name); + $this->assertSame(['id'], $foreignKeyData[$foreignKeyName]->foreign_column_name); + $this->assertSame($this->db->DBPrefix . $tableName, $foreignKeyData[$foreignKeyName]->table_name); + $this->assertSame($this->db->DBPrefix . 'forge_test_users', $foreignKeyData[$foreignKeyName]->foreign_table_name); $this->forge->dropTable($tableName, true); $this->forge->dropTable('forge_test_users', true); @@ -1170,63 +1170,63 @@ public function testCompositeKey(): void if ($this->db->DBDriver === 'MySQLi') { $this->assertSame('PRIMARY', $keys['PRIMARY']->name); - $this->assertSame($keys['PRIMARY']->fields, ['id']); + $this->assertSame(['id'], $keys['PRIMARY']->fields); $this->assertSame('PRIMARY', $keys['PRIMARY']->type); $this->assertSame('code_company', $keys['code_company']->name); - $this->assertSame($keys['code_company']->fields, ['code', 'company']); + $this->assertSame(['code', 'company'], $keys['code_company']->fields); $this->assertSame('INDEX', $keys['code_company']->type); $this->assertSame('code_active', $keys['code_active']->name); - $this->assertSame($keys['code_active']->fields, ['code', 'active']); + $this->assertSame(['code', 'active'], $keys['code_active']->fields); $this->assertSame('UNIQUE', $keys['code_active']->type); } elseif ($this->db->DBDriver === 'Postgre') { $this->assertSame('pk_db_forge_test_1', $keys['pk_db_forge_test_1']->name); - $this->assertSame($keys['pk_db_forge_test_1']->fields, ['id']); + $this->assertSame(['id'], $keys['pk_db_forge_test_1']->fields); $this->assertSame('PRIMARY', $keys['pk_db_forge_test_1']->type); $this->assertSame('db_forge_test_1_code_company', $keys['db_forge_test_1_code_company']->name); - $this->assertSame($keys['db_forge_test_1_code_company']->fields, ['code', 'company']); + $this->assertSame(['code', 'company'], $keys['db_forge_test_1_code_company']->fields); $this->assertSame('INDEX', $keys['db_forge_test_1_code_company']->type); $this->assertSame('db_forge_test_1_code_active', $keys['db_forge_test_1_code_active']->name); - $this->assertSame($keys['db_forge_test_1_code_active']->fields, ['code', 'active']); + $this->assertSame(['code', 'active'], $keys['db_forge_test_1_code_active']->fields); $this->assertSame('UNIQUE', $keys['db_forge_test_1_code_active']->type); } elseif ($this->db->DBDriver === 'SQLite3') { $this->assertSame('PRIMARY', $keys['PRIMARY']->name); - $this->assertSame($keys['PRIMARY']->fields, ['id']); + $this->assertSame(['id'], $keys['PRIMARY']->fields); $this->assertSame('PRIMARY', $keys['PRIMARY']->type); $this->assertSame('db_forge_test_1_code_company', $keys['db_forge_test_1_code_company']->name); - $this->assertSame($keys['db_forge_test_1_code_company']->fields, ['code', 'company']); + $this->assertSame(['code', 'company'], $keys['db_forge_test_1_code_company']->fields); $this->assertSame('INDEX', $keys['db_forge_test_1_code_company']->type); $this->assertSame('db_forge_test_1_code_active', $keys['db_forge_test_1_code_active']->name); - $this->assertSame($keys['db_forge_test_1_code_active']->fields, ['code', 'active']); + $this->assertSame(['code', 'active'], $keys['db_forge_test_1_code_active']->fields); $this->assertSame('UNIQUE', $keys['db_forge_test_1_code_active']->type); } elseif ($this->db->DBDriver === 'SQLSRV') { $this->assertSame('pk_db_forge_test_1', $keys['pk_db_forge_test_1']->name); - $this->assertSame($keys['pk_db_forge_test_1']->fields, ['id']); + $this->assertSame(['id'], $keys['pk_db_forge_test_1']->fields); $this->assertSame('PRIMARY', $keys['pk_db_forge_test_1']->type); $this->assertSame('db_forge_test_1_code_company', $keys['db_forge_test_1_code_company']->name); - $this->assertSame($keys['db_forge_test_1_code_company']->fields, ['code', 'company']); + $this->assertSame(['code', 'company'], $keys['db_forge_test_1_code_company']->fields); $this->assertSame('INDEX', $keys['db_forge_test_1_code_company']->type); $this->assertSame('db_forge_test_1_code_active', $keys['db_forge_test_1_code_active']->name); - $this->assertSame($keys['db_forge_test_1_code_active']->fields, ['code', 'active']); + $this->assertSame(['code', 'active'], $keys['db_forge_test_1_code_active']->fields); $this->assertSame('UNIQUE', $keys['db_forge_test_1_code_active']->type); } elseif ($this->db->DBDriver === 'OCI8') { $this->assertSame('pk_db_forge_test_1', $keys['pk_db_forge_test_1']->name); - $this->assertSame($keys['pk_db_forge_test_1']->fields, ['id']); + $this->assertSame(['id'], $keys['pk_db_forge_test_1']->fields); $this->assertSame('PRIMARY', $keys['pk_db_forge_test_1']->type); $this->assertSame('db_forge_test_1_code_company', $keys['db_forge_test_1_code_company']->name); - $this->assertSame($keys['db_forge_test_1_code_company']->fields, ['code', 'company']); + $this->assertSame(['code', 'company'], $keys['db_forge_test_1_code_company']->fields); $this->assertSame('INDEX', $keys['db_forge_test_1_code_company']->type); $this->assertSame('db_forge_test_1_code_active', $keys['db_forge_test_1_code_active']->name); - $this->assertSame($keys['db_forge_test_1_code_active']->fields, ['code', 'active']); + $this->assertSame(['code', 'active'], $keys['db_forge_test_1_code_active']->fields); $this->assertSame('UNIQUE', $keys['db_forge_test_1_code_active']->type); } diff --git a/tests/system/Database/Live/GetNumRowsTest.php b/tests/system/Database/Live/GetNumRowsTest.php index c7575462d598..302b1e313695 100644 --- a/tests/system/Database/Live/GetNumRowsTest.php +++ b/tests/system/Database/Live/GetNumRowsTest.php @@ -29,28 +29,6 @@ final class GetNumRowsTest extends CIUnitTestCase protected $refresh = true; protected $seed = CITestSeeder::class; - /** - * Added as instructed at https://codeigniter4.github.io/userguide/testing/database.html#the-test-class - * {@inheritDoc} - * - * @see \CodeIgniter\Test\CIDatabaseTestCase::setUp() - */ - protected function setUp(): void - { - parent::setUp(); - } - - /** - * Added as instructed at https://codeigniter4.github.io/userguide/testing/database.html#the-test-class - * {@inheritDoc} - * - * @see \CodeIgniter\Test\CIDatabaseTestCase::tearDown() - */ - protected function tearDown(): void - { - parent::tearDown(); - } - /** * tests newly added ResultInterface::getNumRows with a live db */ diff --git a/tests/system/Database/Live/GetTest.php b/tests/system/Database/Live/GetTest.php index ce98ebf413e0..91035fe6b1d5 100644 --- a/tests/system/Database/Live/GetTest.php +++ b/tests/system/Database/Live/GetTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live; +use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; @@ -227,13 +228,12 @@ public function testGetAnotherDataSeek(): void public function testFreeResult(): void { $data = $this->db->table('job')->where('id', 4)->get(); + $this->assertInstanceOf(BaseResult::class, $data); $details = $data->getResult(); - $this->assertSame('Musician', $details[0]->name); $data->freeResult(); - $this->assertFalse($data->resultID); } @@ -254,13 +254,13 @@ public function testGetRowWithReturnType(): void public function testGetRowWithCustomReturnType(): void { $testClass = new class () { - public $id; - public $name; - public $email; - public $country; - public $created_at; - public $updated_at; - public $deleted_at; + public mixed $id = null; + public mixed $name = null; + public mixed $email = null; + public mixed $country = null; + public mixed $created_at = null; + public mixed $updated_at = null; + public mixed $deleted_at = null; }; $user = $this->db->table('user')->get()->getRow(0, $testClass::class); @@ -292,6 +292,7 @@ public function testGetNextRow(): void public function testGetPreviousRow(): void { $user = $this->db->table('user')->get(); + $this->assertInstanceOf(BaseResult::class, $user); $user->currentRow = 3; diff --git a/tests/system/Database/Live/GetVersionTest.php b/tests/system/Database/Live/GetVersionTest.php index 93678e3b8356..cb4e5c1f5386 100644 --- a/tests/system/Database/Live/GetVersionTest.php +++ b/tests/system/Database/Live/GetVersionTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; @@ -30,6 +31,7 @@ final class GetVersionTest extends CIUnitTestCase public function testGetVersion(): void { if ($this->db->DBDriver === 'MySQLi') { + $this->assertInstanceOf(MySQLiConnection::class, $this->db); $this->db->mysqli = false; } diff --git a/tests/system/Database/Live/MetadataTest.php b/tests/system/Database/Live/MetadataTest.php index 5030a6544231..62f96f443acc 100644 --- a/tests/system/Database/Live/MetadataTest.php +++ b/tests/system/Database/Live/MetadataTest.php @@ -27,7 +27,11 @@ final class MetadataTest extends CIUnitTestCase { use DatabaseTestTrait; - protected $seed = CITestSeeder::class; + protected $seed = CITestSeeder::class; + + /** + * @var list + */ private array $expectedTables = []; protected function setUp(): void diff --git a/tests/system/Database/Live/MySQLi/FoundRowsTest.php b/tests/system/Database/Live/MySQLi/FoundRowsTest.php index b39f8999085d..122af23b24cd 100644 --- a/tests/system/Database/Live/MySQLi/FoundRowsTest.php +++ b/tests/system/Database/Live/MySQLi/FoundRowsTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live\MySQLi; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use Config\Database; @@ -56,6 +57,7 @@ public function testEnableFoundRows(): void $db1 = Database::connect($this->tests); + $this->assertInstanceOf(MySQLiConnection::class, $db1); $this->assertTrue($db1->foundRows); } @@ -65,6 +67,7 @@ public function testDisableFoundRows(): void $db1 = Database::connect($this->tests); + $this->assertInstanceOf(MySQLiConnection::class, $db1); $this->assertFalse($db1->foundRows); } diff --git a/tests/system/Database/Live/MySQLi/NumberNativeTest.php b/tests/system/Database/Live/MySQLi/NumberNativeTest.php index 4469e4c3659a..c419cd6e8e01 100644 --- a/tests/system/Database/Live/MySQLi/NumberNativeTest.php +++ b/tests/system/Database/Live/MySQLi/NumberNativeTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live\MySQLi; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use Config\Database; @@ -27,7 +28,11 @@ final class NumberNativeTest extends CIUnitTestCase { use DatabaseTestTrait; - private $tests; + /** + * @var array + */ + private array $tests = []; + protected $refresh = true; protected $seed = CITestSeeder::class; @@ -50,6 +55,7 @@ public function testEnableNumberNative(): void $this->markTestSkipped('Only MySQLi can complete this test.'); } + $this->assertInstanceOf(MySQLiConnection::class, $db1); $this->assertTrue($db1->numberNative); } @@ -63,6 +69,7 @@ public function testDisableNumberNative(): void $this->markTestSkipped('Only MySQLi can complete this test.'); } + $this->assertInstanceOf(MySQLiConnection::class, $db1); $this->assertFalse($db1->numberNative); } diff --git a/tests/system/Database/Live/OCI8/CallStoredProcedureTest.php b/tests/system/Database/Live/OCI8/CallStoredProcedureTest.php index 32c9efbf0624..50a034e36dc2 100644 --- a/tests/system/Database/Live/OCI8/CallStoredProcedureTest.php +++ b/tests/system/Database/Live/OCI8/CallStoredProcedureTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live\OCI8; +use CodeIgniter\Database\OCI8\Connection as OCI8Connection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; @@ -38,6 +39,8 @@ protected function setUp(): void public function testCallPackageProcedure(): void { + $this->assertInstanceOf(OCI8Connection::class, $this->db); + $result = '0'; $this->db->storedProcedure('calculator.plus', [ @@ -60,6 +63,8 @@ public function testCallPackageProcedure(): void public function testCallStoredProcedure(): void { + $this->assertInstanceOf(OCI8Connection::class, $this->db); + $result = '0'; $this->db->storedProcedure('plus', [ @@ -82,6 +87,8 @@ public function testCallStoredProcedure(): void public function testCallStoredProcedureForCursor(): void { + $this->assertInstanceOf(OCI8Connection::class, $this->db); + $result = $this->db->getCursor(); $this->db->storedProcedure('one', [ @@ -95,6 +102,6 @@ public function testCallStoredProcedureForCursor(): void oci_execute($result); $row = oci_fetch_array($result, OCI_ASSOC + OCI_RETURN_NULLS); - $this->assertSame($row, ['ONE' => '1']); + $this->assertSame(['ONE' => '1'], $row); } } diff --git a/tests/system/Database/Live/OrderTest.php b/tests/system/Database/Live/OrderTest.php index 1451222b5ceb..521078e79e18 100644 --- a/tests/system/Database/Live/OrderTest.php +++ b/tests/system/Database/Live/OrderTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Database\Live; +use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use PHPUnit\Framework\Attributes\Group; @@ -84,6 +85,8 @@ public function testOrderRandom(): void if ($this->db->DBDriver === 'MySQLi') { $key = 'RAND()'; } elseif ($this->db->DBDriver === 'SQLSRV') { + $this->assertInstanceOf(SQLSRVConnection::class, $this->db); + $key = 'NEWID()'; $table = '"' . $this->db->getDatabase() . '"."' . $this->db->schema . '".' . $table; } elseif ($this->db->DBDriver === 'OCI8') { diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index 304269969a2d..5561b310a814 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Query; use CodeIgniter\Database\ResultInterface; +use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection; use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; @@ -76,6 +77,8 @@ public function testPrepareReturnsPreparedQuery(): void } if ($this->db->DBDriver === 'SQLSRV') { + $this->assertInstanceOf(SQLSRVConnection::class, $this->db); + $database = $this->db->getDatabase(); $expected = "INSERT INTO {$ec}{$database}{$ec}.{$ec}{$this->db->schema}{$ec}.{$ec}{$pre}user{$ec} ({$ec}name{$ec},{$ec}email{$ec},{$ec}country{$ec}) VALUES ({$placeholders})"; } else { diff --git a/tests/system/Database/Live/SQLite3/AlterTableTest.php b/tests/system/Database/Live/SQLite3/AlterTableTest.php index 46806b9618fb..a7ba8ad13f03 100644 --- a/tests/system/Database/Live/SQLite3/AlterTableTest.php +++ b/tests/system/Database/Live/SQLite3/AlterTableTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Database\Live\SQLite3; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\SQLite3\Connection; use CodeIgniter\Database\SQLite3\Forge; use CodeIgniter\Database\SQLite3\Table; use CodeIgniter\Test\CIUnitTestCase; @@ -54,9 +55,15 @@ protected function setUp(): void 'database' => ':memory:', 'DBDebug' => true, ]; - $this->db = db_connect($config); - $this->forge = Database::forge($config); - $this->table = new Table($this->db, $this->forge); + $db = db_connect($config); + $this->assertInstanceOf(Connection::class, $db); + $this->db = $db; + + $forge = Database::forge($config); + $this->assertInstanceOf(Forge::class, $forge); + $this->forge = $forge; + + $this->table = new Table($db, $this->forge); $this->dropTables(); } @@ -264,6 +271,68 @@ public function testProcessCopiesOldData(): void $this->seeInDatabase('foo', ['email' => 'funkalicious@example.com']); } + public function testDropColumnKeepsForeignKeyTableNameWhenPrefixIsSet(): void + { + $config = [ + 'DBDriver' => 'SQLite3', + 'database' => ':memory:', + 'DBDebug' => true, + 'DBPrefix' => 'db_', + ]; + + $db = db_connect($config, false); + $this->assertInstanceOf(Connection::class, $db); + + $forge = Database::forge($db); + $this->assertInstanceOf(Forge::class, $forge); + + // The referenced table must begin with a character that also appears in + // the prefix, or the prefix stripping cannot damage its name. + $forge->addField([ + 'id' => [ + 'type' => 'integer', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + ]); + $forge->addPrimaryKey('id'); + $forge->createTable('bandit_fk'); + + $forge->addField([ + 'id' => [ + 'type' => 'integer', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'key_id' => [ + 'type' => 'integer', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'varchar', + 'constraint' => 255, + 'null' => true, + ], + ]); + $forge->addPrimaryKey('id'); + $forge->addForeignKey('key_id', 'bandit_fk', 'id'); + $forge->createTable('bandit'); + + // Dropping a column rebuilds the table, recreating its foreign keys. + $this->assertTrue($forge->dropColumn('bandit', 'name')); + + $keys = array_values($db->getForeignKeyData('bandit')); + + $this->assertCount(1, $keys); + $this->assertSame($db->DBPrefix . 'bandit_fk', $keys[0]->foreign_table_name); + + $forge->dropTable('bandit', true); + $forge->dropTable('bandit_fk', true); + } + protected function createTable(string $tableName = 'foo'): void { // Create support table for foreign keys diff --git a/tests/system/Database/Live/SQLite3/GetIndexDataTest.php b/tests/system/Database/Live/SQLite3/GetIndexDataTest.php index 5ef439644417..854d247daf22 100644 --- a/tests/system/Database/Live/SQLite3/GetIndexDataTest.php +++ b/tests/system/Database/Live/SQLite3/GetIndexDataTest.php @@ -13,7 +13,6 @@ namespace CodeIgniter\Database\Live\SQLite3; -use CodeIgniter\Database\SQLite3\Connection; use CodeIgniter\Database\SQLite3\Forge; use CodeIgniter\Test\CIUnitTestCase; use Config\Database; @@ -26,11 +25,6 @@ #[Group('DatabaseLive')] final class GetIndexDataTest extends CIUnitTestCase { - /** - * @var Connection - */ - protected $db; - private Forge $forge; protected function setUp(): void @@ -47,8 +41,11 @@ protected function setUp(): void 'database' => 'database.db', 'DBDebug' => true, ]; - $this->db = db_connect($config, false); - $this->forge = Database::forge($config); + $this->db = db_connect($config, false); + + $forge = Database::forge($config); + $this->assertInstanceOf(Forge::class, $forge); + $this->forge = $forge; } public function testGetIndexData(): void diff --git a/tests/system/Database/Live/UpdateTest.php b/tests/system/Database/Live/UpdateTest.php index e1fed75e660e..41efa9b86c17 100644 --- a/tests/system/Database/Live/UpdateTest.php +++ b/tests/system/Database/Live/UpdateTest.php @@ -75,7 +75,7 @@ public function testUpdateSetsAllWithoutWhereAndLimit(): void } catch (DatabaseException) { // This DB doesn't support Where and Limit together // but we don't want it called a "Risky" test. - $this->assertTrue(true); + $this->expectNotToPerformAssertions(); } } @@ -110,12 +110,13 @@ public function testUpdateWithWhereAndLimit(): void } catch (DatabaseException) { // This DB doesn't support Where and Limit together // but we don't want it called a "Risky" test. - $this->assertTrue(true); + $this->expectNotToPerformAssertions(); } } /** - * @param array $expected + * @param list> $data + * @param list> $expected */ #[DataProvider('provideUpdateBatch')] public function testUpdateBatch(string $constraints, array $data, array $expected): void @@ -155,6 +156,9 @@ public function testUpdateBatch(string $constraints, array $data, array $expecte $this->seeInDatabase($table, $expected[1]); } + /** + * @return iterable>, list>}> + */ public static function provideUpdateBatch(): iterable { yield from [ @@ -274,6 +278,144 @@ public static function provideUpdateBatch(): iterable ]; } + public function testUpdateBatchWithMixedValueTypesInTextColumn(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $this->markTestSkipped('SQL Server cannot compare `text` columns with `=`.'); + } + + $table = 'type_test'; + + $builder = $this->db->table($table); + $builder->truncate(); + + for ($i = 1; $i < 3; $i++) { + $builder->insert([ + 'type_varchar' => 'test' . $i, + 'type_char' => 'char' . $i, + 'type_text' => 'text', + 'type_smallint' => 32767, + 'type_integer' => 2_147_483_647, + 'type_bigint' => 9_223_372_036_854_775_807, + 'type_float' => 10.1, + 'type_numeric' => 123.23, + 'type_date' => '2023-12-0' . $i, + 'type_datetime' => '2023-12-21 12:00:00', + ]); + } + + $this->db->table($table)->updateBatch([ + ['type_varchar' => 'test1', 'type_text' => 'Example'], + ['type_varchar' => 'test2', 'type_text' => 587], + ], 'type_varchar'); + + $this->seeInDatabase($table, ['type_varchar' => 'test1', 'type_text' => 'Example']); + $this->seeInDatabase($table, ['type_varchar' => 'test2', 'type_text' => '587']); + } + + public function testUpdateBatchDoesNotTruncateConstraintValueForCharColumn(): void + { + $table = 'type_test'; + + $builder = $this->db->table($table); + $builder->truncate(); + + for ($i = 1; $i < 3; $i++) { + $builder->insert([ + 'type_varchar' => 'test' . $i, + 'type_char' => 'char' . $i, + 'type_text' => 'text', + 'type_smallint' => 32767, + 'type_integer' => 2_147_483_647, + 'type_bigint' => 9_223_372_036_854_775_807, + 'type_float' => 10.1, + 'type_numeric' => 123.23, + 'type_date' => '2023-12-0' . $i, + 'type_datetime' => '2023-12-21 12:00:00', + ]); + } + + $this->db->table($table)->updateBatch([ + ['type_char' => 'char1 X', 'type_text' => 'changed'], + ['type_char' => 'char2', 'type_text' => 'changed'], + ], 'type_char'); + + $this->seeInDatabase($table, ['type_varchar' => 'test1', 'type_text' => 'text']); + $this->seeInDatabase($table, ['type_varchar' => 'test2', 'type_text' => 'changed']); + } + + public function testUpdateBatchWithMappedUpdateFieldsAndMixedValueTypes(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $this->markTestSkipped('SQL Server cannot compare `text` columns with `=`.'); + } + + $table = 'type_test'; + + $builder = $this->db->table($table); + $builder->truncate(); + + for ($i = 1; $i < 3; $i++) { + $builder->insert([ + 'type_varchar' => 'test' . $i, + 'type_char' => 'char' . $i, + 'type_text' => 'text', + 'type_smallint' => 32767, + 'type_integer' => 2_147_483_647, + 'type_bigint' => 9_223_372_036_854_775_807, + 'type_float' => 10.1, + 'type_numeric' => 123.23, + 'type_date' => '2023-12-0' . $i, + 'type_datetime' => '2023-12-21 12:00:00', + ]); + } + + $this->db->table($table) + ->setData([ + ['type_varchar' => 'test1', 'text' => 'Example'], + ['type_varchar' => 'test2', 'text' => 587], + ], null, 'data') + ->updateFields(['type_text' => 'text']) + ->onConstraint('type_varchar') + ->updateBatch(); + + $this->seeInDatabase($table, ['type_varchar' => 'test1', 'type_text' => 'Example']); + $this->seeInDatabase($table, ['type_varchar' => 'test2', 'type_text' => '587']); + } + + public function testUpdateBatchKeepsSourceValueForColumnsOfDifferentTypes(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $this->markTestSkipped('SQL Server cannot compare `text` columns with `=`.'); + } + + $table = 'type_test'; + + $builder = $this->db->table($table); + $builder->truncate(); + + $builder->insert([ + 'type_varchar' => 'test1', + 'type_char' => 'char1', + 'type_text' => 'text', + 'type_smallint' => 32767, + 'type_integer' => 2_147_483_647, + 'type_bigint' => 9_223_372_036_854_775_807, + 'type_float' => 10.1, + 'type_numeric' => 123.23, + 'type_date' => '2023-12-01', + 'type_datetime' => '2023-12-21 12:00:00', + ]); + + $this->db->table($table) + ->setData([['type_varchar' => 'test1', 'value' => 1.4]], null, 'data') + ->updateFields(['type_integer' => 'value', 'type_text' => 'value']) + ->onConstraint('type_varchar') + ->updateBatch(); + + $this->seeInDatabase($table, ['type_varchar' => 'test1', 'type_text' => '1.4']); + } + public function testUpdateWithWhereSameColumn(): void { $this->db->table('user')->update(['country' => 'CA'], ['country' => 'US']); diff --git a/tests/system/Database/Migrations/MigrationRunnerTest.php b/tests/system/Database/Migrations/MigrationRunnerTest.php index 510c8169fa34..79ad1cf39330 100644 --- a/tests/system/Database/Migrations/MigrationRunnerTest.php +++ b/tests/system/Database/Migrations/MigrationRunnerTest.php @@ -14,7 +14,9 @@ namespace CodeIgniter\Database\Migrations; use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\MigrationRunner; +use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection; use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Test\CIUnitTestCase; @@ -121,6 +123,7 @@ public function testGetHistory(): void ]; if ($this->db->DBDriver === 'SQLSRV') { + $this->assertInstanceOf(SQLSRVConnection::class, $this->db); $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->prefixTable('migrations') . ' ON'); } @@ -138,6 +141,7 @@ public function testGetHistory(): void $this->assertSame($expected, $history); if ($this->db->DBDriver === 'SQLSRV') { + $this->assertInstanceOf(SQLSRVConnection::class, $this->db); $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->db->escapeIdentifiers($this->db->schema) . '.' . $this->db->prefixTable('migrations') . ' OFF'); $db = $this->getPrivateProperty($runner, 'db'); $db->table('migrations')->delete(['id' => 4]); @@ -477,6 +481,9 @@ public function testMigrationUsesSameConnectionAsMigrationRunner(): void } } + /** + * @param array|ConnectionInterface|string|null $db + */ protected function resetTables($db = null): void { $forge = Database::forge($db); diff --git a/tests/system/Database/Migrations/MigrationTest.php b/tests/system/Database/Migrations/MigrationTest.php index 7e4049a113ce..90d71db35c15 100644 --- a/tests/system/Database/Migrations/MigrationTest.php +++ b/tests/system/Database/Migrations/MigrationTest.php @@ -26,11 +26,6 @@ final class MigrationTest extends CIUnitTestCase { use DatabaseTestTrait; - protected function setUp(): void - { - parent::setUp(); - } - public function testDBGroup(): void { $migration = new class () extends Migration { diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index 43086860b0d6..59eaa8c203d6 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -16,6 +16,7 @@ use App\Controllers\Home; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\HTTP\Response; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\IniTestTrait; use CodeIgniter\Test\StreamFilterTrait; @@ -110,7 +111,8 @@ public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void { $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); - $request = service('incomingrequest', null, false); + $request = service('incomingrequest', null, false); + /** @var Response $response */ $response = service('response', null, false); $response->pretend(); @@ -131,6 +133,7 @@ public function testHandleWebPageNotFoundExceptionAcceptHTML(): void $request = service('incomingrequest', null, false); $request->setHeader('accept', 'text/html'); + /** @var Response $response */ $response = service('response', null, false); $response->pretend(); @@ -147,6 +150,7 @@ public function testHandleCLIPageNotFoundException(): void $request = Services::clirequest(null, false); $request->setHeader('accept', 'text/html'); + /** @var Response $response */ $response = service('response', null, false); $response->pretend(); diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index 919c1ea85180..08f006637bcb 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\Honeypot\Exceptions\HoneypotException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ReflectionHelper; use Config\Exceptions as ExceptionsConfig; @@ -124,6 +125,7 @@ public function testDetermineCodes(): void $this->assertSame([500, EXIT_CONFIG], $determineCodes(new ConfigException('This.'))); $this->assertSame([500, EXIT_CONFIG], $determineCodes(CastException::forInvalidInterface('This.'))); $this->assertSame([500, EXIT_DATABASE], $determineCodes(new DatabaseException('This.'))); + $this->assertSame([403, EXIT_CONFIG], $determineCodes(HoneypotException::isBot())); } public function testMaskSensitiveData(): void diff --git a/tests/system/Debug/TimerTest.php b/tests/system/Debug/TimerTest.php index 756c2ce50340..8ec9a3602c2b 100644 --- a/tests/system/Debug/TimerTest.php +++ b/tests/system/Debug/TimerTest.php @@ -86,6 +86,16 @@ public function testElapsedTimeGivesSameResultAsTimersArray(): void $this->assertSame($expected, $timer->getElapsedTime('test1')); } + public function testStartWithZeroTime(): void + { + $timer = new Timer(); + $timer->start('test1', 0.0); + + $timers = $timer->getTimers(); + + $this->assertEqualsWithDelta(0.0, $timers['test1']['start'], PHP_FLOAT_EPSILON); + } + public function testThrowsExceptionStoppingNonTimer(): void { $this->expectException('RunTimeException'); @@ -176,7 +186,7 @@ public function testRecordThrowsErrorOnCallableWithParams(): void $this->expectException(ArgumentCountError::class); $timer = new Timer(); - $timer->record('error', 'strlen'); + $timer->record('error', 'strlen'); // @phpstan-ignore argument.type (Needed for testing) } public function testCommonNoNameExpectTimer(): void diff --git a/tests/system/Email/EmailTest.php b/tests/system/Email/EmailTest.php index 9ca5c5a51a4a..a54bf8d90ce5 100644 --- a/tests/system/Email/EmailTest.php +++ b/tests/system/Email/EmailTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Email; +use Closure; use CodeIgniter\Events\Events; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockEmail; @@ -57,6 +58,9 @@ public function testEmailSendWithClearance($autoClear): void } } + /** + * @return iterable> + */ public static function provideEmailSendWithClearance(): iterable { return [ @@ -409,4 +413,479 @@ public function testPrepQuotedPrintableUnwrapRemovesTagsOnly(): void $this->assertSame('keep =7Bbraces=7D', $prepQP('keep {braces}')); $this->assertSame('keep (parentheses)', $prepQP('keep (parentheses)')); } + + public function testBuildMessagePlain(): void + { + $email = new Email(); + $email->setMessage('This is a simple plain text message.'); + $email->setMailType('text'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('This is a simple plain text message.', (string) $finalBody); + $this->assertStringContainsString('Content-Type: text/plain', (string) $headerStr); + } + + public function testBuildMessageHtmlNoMultipart(): void + { + $email = new Email(); + $email->setMessage('

This is a HTML message.

'); + $email->setMailType('html'); + $this->setPrivateProperty($email, 'sendMultipart', false); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('

This is a HTML message.

', (string) $finalBody); + $this->assertStringContainsString('Content-Type: text/html', (string) $headerStr); + $this->assertStringNotContainsString('multipart/alternative', (string) $headerStr); + } + + public function testBuildMessageHtmlMultipart(): void + { + $email = new Email(); + $email->setMessage('

This is a HTML message.

'); + $email->setMailType('html'); + $this->setPrivateProperty($email, 'sendMultipart', true); + $email->setAltMessage('This is alternative text.'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('

This is a HTML message.

', (string) $finalBody); + $this->assertStringContainsString('Content-Type: multipart/alternative', (string) $headerStr); + $this->assertStringContainsString('This is alternative text.', (string) $finalBody); + } + + public function testBuildMessagePlainWithAttachments(): void + { + $email = new Email(); + $email->setMessage('Plain text with attachments.'); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $headerStr = $this->getPrivateProperty($email, 'headerStr'); + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('Content-Type: multipart/mixed', (string) $headerStr); + $this->assertStringContainsString('Content-Type: image/png', (string) $finalBody); + $this->assertStringContainsString('Content-Disposition: attachment', (string) $finalBody); + } + + public function testWordWrap(): void + { + $email = new Email(); + $email->setNewline(); + + $text = 'This is a very long line of text that should be wrapped to a smaller character limit.'; + $wrapped = $email->wordWrap($text, 20); + + // Verify that long text gets wrapped at roughly 20 chars + $lines = explode("\n", trim($wrapped)); + + foreach ($lines as $line) { + $this->assertLessThanOrEqual(20, strlen($line)); + } + + // Verify unwrap behavior + $unwrapText = 'This is {unwrap}a very long word or link that should not be wrapped under any circumstances{/unwrap} even if it exceeds the limit.'; + $wrappedUnwrap = $email->wordWrap($unwrapText, 20); + $this->assertStringContainsString('a very long word or link that should not be wrapped under any circumstances', $wrappedUnwrap); + } + + public function testCleanEmail(): void + { + $email = new Email(); + + // Test with string input + $this->assertSame('john@example.com', $email->cleanEmail('John Doe ')); + $this->assertSame('simple@example.com', $email->cleanEmail('simple@example.com')); + + // Test with array input + $emails = [ + 'John Doe ', + 'simple@example.com', + '', + ]; + $expected = [ + 'john@example.com', + 'simple@example.com', + 'jane@example.com', + ]; + $this->assertSame($expected, $email->cleanEmail($emails)); + } + + public function testValidateEmail(): void + { + $email = new Email(); + + // Must be array + $this->assertFalse($email->validateEmail('string@example.com')); + + // Valid array + $this->assertTrue($email->validateEmail(['test@example.com', 'another@example.com'])); + + // Invalid array + $this->assertFalse($email->validateEmail(['test@example.com', 'invalid-email'])); + } + + public function testIsValidEmail(): void + { + $email = new Email(); + + $this->assertTrue($email->isValidEmail('test@example.com')); + $this->assertFalse($email->isValidEmail('invalid-email')); + $this->assertFalse($email->isValidEmail('test@example')); + + // IDN domain (internationalized) + if (function_exists('idn_to_ascii') && defined('INTL_IDNA_VARIANT_UTS46')) { + $this->assertTrue($email->isValidEmail('user@müller.de')); + } + } + + public function testClear(): void + { + $email = new Email(); + $email->setTo('test@example.com'); + $email->setSubject('My Subject'); + $email->setMessage('Hello'); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png'); + + // Verify state before clear + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertSame('=?UTF-8?Q?My=20Subject?=', $headers['Subject']); + $this->assertNotEmpty($this->getPrivateProperty($email, 'attachments')); + + // Clear without clearing attachments + $email->clear(false); + $headersAfter = $this->getPrivateProperty($email, 'headers'); + $this->assertCount(1, $headersAfter); + $this->assertArrayHasKey('Date', $headersAfter); + $this->assertNotEmpty($this->getPrivateProperty($email, 'attachments')); + + // Clear with clearing attachments + $email->clear(true); + $this->assertEmpty($this->getPrivateProperty($email, 'attachments')); + } + + public function testSetters(): void + { + $email = new Email(); + + $email->setMailType('html'); + $this->assertSame('html', $this->getPrivateProperty($email, 'mailType')); + + $email->setWordWrap(false); + $this->assertFalse($this->getPrivateProperty($email, 'wordWrap')); + + $email->setProtocol('smtp'); + $this->assertSame('smtp', $this->getPrivateProperty($email, 'protocol')); + + $email->setPriority(1); + $this->assertSame(1, $this->getPrivateProperty($email, 'priority')); + + $email->setNewline("\r\n"); + $this->assertSame("\r\n", $this->getPrivateProperty($email, 'newline')); + + $email->setCRLF("\r\n"); + $this->assertSame("\r\n", $this->getPrivateProperty($email, 'CRLF')); + } + + public function testSetFrom(): void + { + $email = new Email(); + $email->setFrom('john@example.com', 'John Doe'); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertSame('"John Doe" ', $headers['From']); + $this->assertSame('', $headers['Return-Path']); + + // Testing Q Encoding + $email->setFrom('john@example.com', 'Jöhn Døe'); + $headersQ = $this->getPrivateProperty($email, 'headers'); + $this->assertStringContainsString('=?UTF-8?Q?', (string) $headersQ['From']); + } + + public function testSetReplyTo(): void + { + $email = new Email(); + $email->setReplyTo('support@example.com', 'Support Team'); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertSame('"Support Team" ', $headers['Reply-To']); + $this->assertTrue($this->getPrivateProperty($email, 'replyToFlag')); + } + + public function testSetToCCBCC(): void + { + $email = new Email(); + $email->setProtocol('smtp'); + + $email->setTo('one@example.com, two@example.com'); + $this->assertSame(['one@example.com', 'two@example.com'], $this->getPrivateProperty($email, 'recipients')); + + $email->setCC('cc@example.com'); + $this->assertSame(['cc@example.com'], $this->getPrivateProperty($email, 'CCArray')); + + $email->setBCC('bcc@example.com'); + $this->assertSame(['bcc@example.com'], $this->getPrivateProperty($email, 'BCCArray')); + } + + public function testAttach(): void + { + $email = new Email(); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png', 'attachment', 'new-name.png'); + + $attachments = $this->getPrivateProperty($email, 'attachments'); + $this->assertCount(1, $attachments); + $this->assertSame('new-name.png', $attachments[0]['name'][1]); + $this->assertSame('attachment', $attachments[0]['disposition']); + $this->assertSame('image/png', $attachments[0]['type']); + } + + public function testPrintDebugger(): void + { + $email = new Email(); + $this->setPrivateProperty($email, 'headerStr', 'To: recipient@example.com'); + $this->setPrivateProperty($email, 'subject', 'Debug Test'); + $this->setPrivateProperty($email, 'finalBody', 'Testing debugger'); + $this->setPrivateProperty($email, 'debugMessage', ['SMTP success message
']); + + $debug = $email->printDebugger(); + $this->assertStringContainsString('SMTP success message', $debug); + $this->assertStringContainsString('Debug Test', $debug); + $this->assertStringContainsString('recipient@example.com', $debug); + } + + public function testSettersFallback(): void + { + $email = new Email(); + + // Invalid newline falls back to "\n" + $email->setNewline('invalid'); + $this->assertSame("\n", $this->getPrivateProperty($email, 'newline')); + + // Invalid CRLF falls back to "\n" + $email->setCRLF('invalid'); + $this->assertSame("\n", $this->getPrivateProperty($email, 'CRLF')); + } + + public function testGetMessageID(): void + { + $email = new Email(); + $email->setFrom('john@example.com'); + + $getMessageID = self::getPrivateMethodInvoker($email, 'getMessageID'); + $messageID = $getMessageID(); + + $this->assertStringStartsWith('<', $messageID); + $this->assertStringEndsWith('>', $messageID); + $this->assertStringContainsString('@example.com', $messageID); + } + + public function testValidateEmailForShell(): void + { + $email = new Email(); + + $validateForShell = Closure::bind(fn (&$email) => $this->validateEmailForShell($email), $email, Email::class); + + $address = 'test@example.com'; + $this->assertTrue($validateForShell($address)); + + $invalidAddress = 'invalid-email-address'; + $this->assertFalse($validateForShell($invalidAddress)); + + $shellInjection = 'test@example.com; rm -rf /'; + $this->assertFalse($validateForShell($shellInjection)); + } + + public function testSetPriorityFallback(): void + { + $email = new Email(); + + // Priority 6 is invalid, falls back to 3 + $email->setPriority(6); + $priority = $this->getPrivateProperty($email, 'priority'); + $this->assertSame(3, $priority); + + // Priority 0 is invalid, falls back to 3 + $email->setPriority(0); + $this->assertSame(3, $this->getPrivateProperty($email, 'priority')); + } + + public function testSMTPAuthenticateValidation(): void + { + $email = new Email(); + + $smtpAuthenticate = self::getPrivateMethodInvoker($email, 'SMTPAuthenticate'); + + // SMTPAuth false returns true immediately + $this->setPrivateProperty($email, 'SMTPAuth', false); + $this->assertTrue($smtpAuthenticate()); + + // SMTPAuth true but empty credentials returns false + $this->setPrivateProperty($email, 'SMTPAuth', true); + $this->setPrivateProperty($email, 'SMTPUser', ''); + $this->setPrivateProperty($email, 'SMTPPass', ''); + $this->assertFalse($smtpAuthenticate()); + + // Invalid SMTP auth method returns false + $this->setPrivateProperty($email, 'SMTPUser', 'user'); + $this->setPrivateProperty($email, 'SMTPPass', 'pass'); + $this->setPrivateProperty($email, 'SMTPAuthMethod', 'oauth'); + $this->assertFalse($smtpAuthenticate()); + } + + public function testAttachMissingFile(): void + { + $email = new Email(); + $this->assertFalse($email->attach('/path/to/nonexistent/file.txt')); + } + + public function testSetAttachmentCID(): void + { + $email = new Email(); + + // Attach from buffer + $email->attach('file_content', 'inline', 'photo.jpg', 'image/jpeg'); + + $cid = $email->setAttachmentCID('photo.jpg'); + $this->assertIsString($cid); + $this->assertStringContainsString('photo.jpg@', $cid); + + // Non-existent file/buffer name + $this->assertFalse($email->setAttachmentCID('nonexistent.jpg')); + } + + public function testSmtpConnectAlreadyConnected(): void + { + $email = new Email(); + $this->setPrivateProperty($email, 'SMTPConnect', fopen('php://memory', 'r+b')); + + $smtpConnect = self::getPrivateMethodInvoker($email, 'SMTPConnect'); + $this->assertTrue($smtpConnect()); + } + + public function testBuildMessageHtmlWithInlineAttachments(): void + { + $email = new Email(); + $email->setMailType('html'); + $email->setFrom('sender@example.com'); + $email->setTo('recipient@example.com'); + $email->setSubject('Inline Test'); + $email->setMessage('

Test HTML

'); + $email->attach('image_data', 'inline', 'image.jpg', 'image/jpeg'); + $email->setAttachmentCID('image.jpg'); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $body = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('Content-Disposition: inline', (string) $body); + $this->assertStringContainsString('Content-ID:', (string) $body); + } + + public function testMimeTypesUnknown(): void + { + $email = new Email(); + $mimeTypes = self::getPrivateMethodInvoker($email, 'mimeTypes'); + $this->assertSame('application/x-unknown-content-type', $mimeTypes('invalid_ext')); + } + + public function testAttachRealFileDetectMime(): void + { + $email = new Email(); + $this->assertNotFalse($email->attach(__FILE__)); + + $attachments = $this->getPrivateProperty($email, 'attachments'); + $this->assertCount(1, $attachments); + $this->assertSame('application/x-php', $attachments[0]['type']); + } + + public function testValidateEmailIdn(): void + { + $email = new Email(); + $validateEmail = self::getPrivateMethodInvoker($email, 'validateEmail'); + $address = ['test@przykłady.pl']; + $this->assertTrue($validateEmail($address)); + } + + public function testSetSubjectPreventsHeaderInjection(): void + { + $email = new Email(); + $email->setSubject("Test Subject\r\nBcc: spy@example.com"); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertStringNotContainsString('Bcc: spy@example.com', (string) $headers['Subject']); + } + + public function testSetSubjectExtremelyLongUtf8(): void + { + $email = new Email(); + $subject = str_repeat('Zażółć gęślą jaźń ', 10); + $email->setSubject($subject); + + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertStringStartsWith('=?UTF-8?Q?', $headers['Subject']); + } + + public function testAttachWithUtf8Filename(): void + { + $email = new Email(); + $email->attach(SUPPORTPATH . 'Images/ci-logo.png', 'attachment', 'żółć-logo.png'); + + $attachments = $this->getPrivateProperty($email, 'attachments'); + $this->assertSame('żółć-logo.png', $attachments[0]['name'][1]); + + $buildMessage = self::getPrivateMethodInvoker($email, 'buildMessage'); + $buildMessage(); + + $finalBody = $this->getPrivateProperty($email, 'finalBody'); + $this->assertStringContainsString('name="żółć-logo.png"', (string) $finalBody); + } + + public function testGetProtocolFallback(): void + { + $email = new Email(); + $email->setProtocol('invalid'); + $getProtocol = self::getPrivateMethodInvoker($email, 'getProtocol'); + $this->assertSame('mail', $getProtocol()); + } + + public function testSetBccBatchMode(): void + { + $email = new Email(); + $email->setBCC('bcc@example.com', '10'); + $this->assertTrue($this->getPrivateProperty($email, 'BCCBatchMode')); + $this->assertSame('10', $this->getPrivateProperty($email, 'BCCBatchSize')); + } + + public function testSettersWithArray(): void + { + $email = new Email(); + $email->setTo(['one@example.com', 'two@example.com']); + $this->assertSame(['one@example.com', 'two@example.com'], $this->getPrivateProperty($email, 'recipients')); + } + + public function testSetFromAndReplyToWithValidation(): void + { + $config = config('Email'); + $config->validate = true; + $email = new Email($config); + + $email->setFrom('john@example.com', 'Jöhn Døe'); + $headers = $this->getPrivateProperty($email, 'headers'); + $this->assertStringContainsString('=?UTF-8?Q?', (string) $headers['From']); + + $email->setReplyTo('support@example.com', 'Jöhn Døe'); + $this->assertTrue($this->getPrivateProperty($email, 'replyToFlag')); + } } diff --git a/tests/system/Encryption/EncryptionTest.php b/tests/system/Encryption/EncryptionTest.php index 48fd6dc78e73..81c4554336e0 100644 --- a/tests/system/Encryption/EncryptionTest.php +++ b/tests/system/Encryption/EncryptionTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Config\Services as CodeIgniterServices; use CodeIgniter\Encryption\Exceptions\EncryptionException; +use CodeIgniter\Encryption\Handlers\OpenSSLHandler; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\Encryption as EncryptionConfig; @@ -156,6 +157,7 @@ public function testServiceShared(): void $config->key = 'Abracadabra'; $encrypter = Services::encrypter($config, true); + $this->assertInstanceOf(OpenSSLHandler::class, $encrypter); $this->assertSame('anything', $encrypter->key); } @@ -166,7 +168,7 @@ public function testMagicIssetTrue(): void public function testMagicIssetFalse(): void { - $this->assertFalse(isset($this->encryption->bogus)); + $this->assertFalse(isset($this->encryption->bogus)); // @phpstan-ignore property.notFound } public function testMagicGet(): void @@ -176,7 +178,7 @@ public function testMagicGet(): void public function testMagicGetMissing(): void { - $this->assertNull($this->encryption->bogus); + $this->assertNull($this->encryption->bogus); // @phpstan-ignore property.notFound } public function testDecryptEncryptedDataByCI3AES128CBC(): void diff --git a/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php b/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php index e883c5b2fbc6..01254f31b810 100644 --- a/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php +++ b/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php @@ -46,6 +46,7 @@ public function testSanity(): void $params->key = 'Something other than an empty string'; $encrypter = $this->encryption->initialize($params); + $this->assertInstanceOf(OpenSSLHandler::class, $encrypter); $this->assertSame('AES-256-CTR', $encrypter->cipher); $this->assertSame('Something other than an empty string', $encrypter->key); @@ -64,6 +65,7 @@ public function testSimple(): void $params->key = '\xd0\xc9\x08\xc4\xde\x52\x12\x6e\xf8\xcc\xdb\x03\xea\xa0\x3a\x5c'; // Default state (AES-256/Rijndael-256 in CTR mode) $encrypter = $this->encryption->initialize($params); + $this->assertInstanceOf(OpenSSLHandler::class, $encrypter); // Was the key properly set? $this->assertSame($params->key, $encrypter->key); @@ -145,8 +147,10 @@ public function testInternalKeyNotModifiedByParams(): void $params->key = 'original-key-value'; $encrypter = $this->encryption->initialize($params); + $this->assertInstanceOf(OpenSSLHandler::class, $encrypter); - $this->assertSame('original-key-value', $encrypter->key); + $key = $encrypter->key; + $this->assertSame('original-key-value', $key); $message = 'This is a plain-text message.'; $differentKey = 'temporary-param-key'; diff --git a/tests/system/Encryption/Handlers/SodiumHandlerTest.php b/tests/system/Encryption/Handlers/SodiumHandlerTest.php index 09b1784e4a12..fe63cec36fb9 100644 --- a/tests/system/Encryption/Handlers/SodiumHandlerTest.php +++ b/tests/system/Encryption/Handlers/SodiumHandlerTest.php @@ -47,10 +47,11 @@ public function testPropertiesGetter(): void $this->config->key = sodium_crypto_secretbox_keygen(); $this->config->blockSize = 256; $encrypter = $this->encryption->initialize($this->config); + $this->assertInstanceOf(SodiumHandler::class, $encrypter); $this->assertSame($this->config->key, $encrypter->key); $this->assertSame($this->config->blockSize, $encrypter->blockSize); - $this->assertNull($encrypter->driver); + $this->assertNull($encrypter->driver); // @phpstan-ignore property.notFound } public function testEmptyKeyThrowsErrorOnInitialize(): void @@ -136,8 +137,10 @@ public function testInternalKeyNotModifiedByParams(): void $this->config->key = $originalKey; $encrypter = $this->encryption->initialize($this->config); + $this->assertInstanceOf(SodiumHandler::class, $encrypter); - $this->assertSame($originalKey, $encrypter->key); + $key = $encrypter->key; + $this->assertSame($originalKey, $key); $message = 'This is a plain-text message.'; $differentKey = sodium_crypto_secretbox_keygen(); diff --git a/tests/system/Encryption/KeyRotationDecoratorTest.php b/tests/system/Encryption/KeyRotationDecoratorTest.php index e94ea16bcd65..1f86220739db 100644 --- a/tests/system/Encryption/KeyRotationDecoratorTest.php +++ b/tests/system/Encryption/KeyRotationDecoratorTest.php @@ -193,6 +193,7 @@ public function testPropertyAccessDelegation(): void $params->previousKeys = ['old-key']; $encrypter = $this->encryption->initialize($params); + $this->assertInstanceOf(KeyRotationDecorator::class, $encrypter); $this->assertSame('AES-128-CBC', $encrypter->cipher); $this->assertSame('test-key-very-long', $encrypter->key); diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 5233a28deeaf..3aff3fac3a10 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -373,8 +373,9 @@ public function testCastInteger(): void $entity->first = 3.1; - $this->assertIsInt($entity->first); - $this->assertSame(3, $entity->first); + $first = $entity->first; + $this->assertIsInt($first); + $this->assertSame(3, $first); $entity->first = 3.6; @@ -502,22 +503,18 @@ public function testCastDateTime(): void public function testCastDateTimeWithTimestampTimezone(): void { - // Save the current timezone. $tz = date_default_timezone_get(); - - // Change the timezone other than UTC. - date_default_timezone_set('Asia/Tokyo'); // +09:00 + date_default_timezone_set('Asia/Tokyo'); $entity = $this->getCastEntity(); - $entity->eighth = 1722988800; // 2024-08-07 00:00:00 UTC + $entity->eighth = 1_722_988_800; // 2024-08-07 00:00:00 UTC - $this->assertInstanceOf(DateTimeInterface::class, $entity->eighth); + $this->assertInstanceOf(Time::class, $entity->eighth); // The timezone is the default timezone, not UTC. $this->assertSame('2024-08-07 09:00:00', $entity->eighth->format('Y-m-d H:i:s')); $this->assertSame('Asia/Tokyo', $entity->eighth->getTimezoneName()); - // Restore timezone. date_default_timezone_set($tz); } @@ -651,8 +648,8 @@ public function testCastAsJSON(): void $check = $this->getPrivateProperty($entity, 'attributes')['tenth']; $this->assertSame('{"foo":"bar"}', $check); - $this->assertInstanceOf('stdClass', $entity->tenth); - $this->assertSame(['foo' => 'bar'], (array) $entity->tenth); + $this->assertInstanceOf(stdClass::class, $entity->tenth); + $this->assertSame('bar', $entity->tenth->foo); } public function testCastAsJSONArray(): void @@ -1744,7 +1741,10 @@ private function getSimpleSwappedEntity(): object }; } - private function getCastEntity($data = null): object + /** + * @param array|null $data + */ + private function getCastEntity(?array $data = null): Entity { return new class ($data) extends Entity { protected $attributes = [ diff --git a/tests/system/Events/EventsTest.php b/tests/system/Events/EventsTest.php index bfdef1e2bbe9..39be06b1235b 100644 --- a/tests/system/Events/EventsTest.php +++ b/tests/system/Events/EventsTest.php @@ -69,7 +69,8 @@ public function testInitialize(): void // but we should be able to change it through the backdoor MockEvents::setFiles(['/peanuts']); - $this->assertSame(['/peanuts'], Events::getFiles()); + $files = Events::getFiles(); + $this->assertSame(['/peanuts'], $files); // re-initializing should have no effect MockEvents::initialize(); diff --git a/tests/system/Files/FileWithVfsTest.php b/tests/system/Files/FileWithVfsTest.php index a6c26383d187..e0065591963d 100644 --- a/tests/system/Files/FileWithVfsTest.php +++ b/tests/system/Files/FileWithVfsTest.php @@ -150,4 +150,13 @@ public function testMoveReturnsNewInstance(): void $this->assertInstanceOf(File::class, $file); $this->assertSame($destination . '/apple.php', $file->getPathname()); } + + public function testMovePermissions(): void + { + $destination = $this->start . 'baker'; + $this->file->move($destination); + + $expectedPerms = 0666 & ~umask(); + $this->assertSame($expectedPerms, $this->root->getChild('baker/apple.php')->getPermissions()); + } } diff --git a/tests/system/Filters/CSRFTest.php b/tests/system/Filters/CSRFTest.php index d1977e4f5cea..d258755f4870 100644 --- a/tests/system/Filters/CSRFTest.php +++ b/tests/system/Filters/CSRFTest.php @@ -40,6 +40,9 @@ final class CSRFTest extends CIUnitTestCase protected function setUp(): void { parent::setUp(); + + $this->resetServices(); + $this->config = new \Config\Filters(); } @@ -50,8 +53,11 @@ public function testDoNotCheckCliRequest(): void 'after' => [], ]; - $this->request = Services::clirequest(null, false); - $this->response = service('response'); + $this->request = Services::clirequest(null, false); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $filters = new Filters($this->config, $this->request, $this->response); $uri = 'admin/foo/bar'; @@ -68,8 +74,11 @@ public function testPassGetRequest(): void 'after' => [], ]; - $this->request = service('incomingrequest', null, false); - $this->response = service('response'); + $this->request = service('incomingrequest', null, false); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $filters = new Filters($this->config, $this->request, $this->response); $uri = 'admin/foo/bar'; diff --git a/tests/system/Filters/DebugToolbarTest.php b/tests/system/Filters/DebugToolbarTest.php index 905dd46cf15a..fc9f8f179776 100644 --- a/tests/system/Filters/DebugToolbarTest.php +++ b/tests/system/Filters/DebugToolbarTest.php @@ -45,8 +45,11 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); - $this->request = service('request'); - $this->response = service('response'); + $this->request = service('request'); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; } public function testDebugToolbarFilter(): void diff --git a/tests/system/Filters/FiltersTest.php b/tests/system/Filters/FiltersTest.php index b632df217043..d696498efaa2 100644 --- a/tests/system/Filters/FiltersTest.php +++ b/tests/system/Filters/FiltersTest.php @@ -24,6 +24,7 @@ use CodeIgniter\Filters\fixtures\Multiple2; use CodeIgniter\Filters\fixtures\Role; use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Superglobals; @@ -68,13 +69,14 @@ protected function setUp(): void ]; service('autoloader')->addNamespace($defaults); - service('superglobals')->setServerArray([]); Services::injectMock('superglobals', new Superglobals()); - $this->response = service('response'); + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; } - private function createFilters(FiltersConfig $config, $request = null): Filters + private function createFilters(FiltersConfig $config, ?RequestInterface $request = null): Filters { $request ??= service('request'); @@ -214,10 +216,10 @@ public function testProcessMethodProcessGlobals(): void } /** - * @param array|string $except + * @param list|string $except */ #[DataProvider('provideProcessMethodProcessGlobalsWithExcept')] - public function testProcessMethodProcessGlobalsWithExcept($except): void + public function testProcessMethodProcessGlobalsWithExcept(array|string $except): void { service('superglobals')->setServer('REQUEST_METHOD', 'GET'); @@ -250,6 +252,9 @@ public function testProcessMethodProcessGlobalsWithExcept($except): void $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } + /** + * @return iterable|string}> + */ public static function provideProcessMethodProcessGlobalsWithExcept(): iterable { return [ @@ -591,10 +596,11 @@ public function testOtherResult(): void } /** - * @param array|string $except + * @param list|string $except + * @param array{before: list, after: list} $expected */ #[DataProvider('provideBeforeExcept')] - public function testBeforeExcept(string $uri, $except, array $expected): void + public function testBeforeExcept(string $uri, array|string $except, array $expected): void { service('superglobals')->setServer('REQUEST_METHOD', 'GET'); @@ -620,6 +626,9 @@ public function testBeforeExcept(string $uri, $except, array $expected): void $this->assertSame($expected, $filters->initialize($uri)->getFilters()); } + /** + * @return iterable|string, array{before: list, after: list}}> + */ public static function provideBeforeExcept(): iterable { return [ diff --git a/tests/system/Filters/HoneypotTest.php b/tests/system/Filters/HoneypotTest.php index 630a882352ec..8ad3106f1a9f 100644 --- a/tests/system/Filters/HoneypotTest.php +++ b/tests/system/Filters/HoneypotTest.php @@ -65,8 +65,11 @@ public function testBeforeTriggered(): void 'after' => [], ]; - $this->request = service('request', null, false); - $this->response = service('response'); + $this->request = service('request', null, false); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $filters = new Filters($this->config, $this->request, $this->response); $uri = 'admin/foo/bar'; @@ -83,8 +86,11 @@ public function testBeforeClean(): void ]; service('superglobals')->unsetPost($this->honey->name); - $this->request = service('request', null, false); - $this->response = service('response'); + $this->request = service('request', null, false); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $expected = $this->request; @@ -104,14 +110,19 @@ public function testAfter(): void 'after' => ['honeypot'], ]; - $this->request = service('request', null, false); - $this->response = service('response'); + $this->request = service('request', null, false); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $filters = new Filters($this->config, $this->request, $this->response); $uri = 'admin/foo/bar'; $this->response->setBody('
'); - $this->response = $filters->run($uri, 'after'); + $response = $filters->run($uri, 'after'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $this->assertStringContainsString($this->honey->name, (string) $this->response->getBody()); } @@ -124,14 +135,19 @@ public function testAfterNotApplicable(): void 'after' => ['honeypot'], ]; - $this->request = service('request', null, false); - $this->response = service('response'); + $this->request = service('request', null, false); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $filters = new Filters($this->config, $this->request, $this->response); $uri = 'admin/foo/bar'; $this->response->setBody('
'); - $this->response = $filters->run($uri, 'after'); + $response = $filters->run($uri, 'after'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $this->assertStringNotContainsString($this->honey->name, (string) $this->response->getBody()); } } diff --git a/tests/system/Filters/InvalidCharsTest.php b/tests/system/Filters/InvalidCharsTest.php index 4cf112b6da91..66588b5b1a35 100644 --- a/tests/system/Filters/InvalidCharsTest.php +++ b/tests/system/Filters/InvalidCharsTest.php @@ -161,6 +161,9 @@ public function testCheckControlStringWithLineBreakAndTabReturnsTheString(string $this->invalidChars->before($this->request); } + /** + * @return iterable + */ public static function provideCheckControlStringWithLineBreakAndTabReturnsTheString(): iterable { yield from [ @@ -183,6 +186,9 @@ public function testCheckControlStringWithControlCharsCausesException(string $in $this->invalidChars->before($this->request); } + /** + * @return iterable + */ public static function provideCheckControlStringWithControlCharsCausesException(): iterable { yield from [ diff --git a/tests/system/HTTP/CLIRequestTest.php b/tests/system/HTTP/CLIRequestTest.php index 0bf3da0a6848..b9ce3dc5f7ce 100644 --- a/tests/system/HTTP/CLIRequestTest.php +++ b/tests/system/HTTP/CLIRequestTest.php @@ -431,7 +431,6 @@ public function testFetchGlobalReturnsArrayValues(): void $result = $this->request->fetchGlobal('post'); $this->assertSame($post, $result); - $this->assertIsArray($result['ANNOUNCEMENTS']); $this->assertCount(2, $result['ANNOUNCEMENTS']); } @@ -567,11 +566,22 @@ public function testGetCookie(): void { $this->assertNull($this->request->getCookie('TESTY')); - $this->assertSame($this->request->getCookie(), []); + $this->assertSame([], $this->request->getCookie()); } public function testIs(): void { $this->assertFalse($this->request->is('get')); } + + public function testParseCommandWithMissingArgv(): void + { + Services::injectMock('superglobals', new Superglobals([], [], [], [], [])); + + $request = new CLIRequest(new App()); + + $this->assertSame('', $request->getPath()); + $this->assertSame([], $request->getSegments()); + $this->assertSame('', $request->getOptionString()); + } } diff --git a/tests/system/HTTP/CURLRequestExceptionTest.php b/tests/system/HTTP/CURLRequestExceptionTest.php new file mode 100644 index 000000000000..581d66775c87 --- /dev/null +++ b/tests/system/HTTP/CURLRequestExceptionTest.php @@ -0,0 +1,310 @@ + + * + * 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\HTTP\Exceptions\HTTPException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockCURLRequest; +use Config\App; +use Config\CURLRequest as ConfigCURLRequest; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Throwable; + +/** + * @internal + */ +#[Group('Others')] +final class CURLRequestExceptionTest extends CIUnitTestCase +{ + #[DataProvider('provideFailureResetsSetterOptions')] + public function testFailureResetsSetterOptions(string $failure, string $setter): void + { + $this->configureSharing(false); + + $app = new App(); + $request = $failure === 'transport' + ? $this->failingRequest() + : new MockCURLRequest($app, new URI(), new Response($app)); + + $request->setHeader('Authorization', 'Bearer secret'); + + match ($setter) { + 'auth' => $request->setAuth('user', 'password'), + 'body' => $request->setBody('private body'), + 'form' => $request->setForm(['private' => 'data']), + 'multipart' => $request->setForm(['private' => 'data'], true), + 'json' => $request->setJSON(['private' => 'data']), + default => throw new InvalidArgumentException('Unknown setter: ' . $setter), + }; + + $options = match ($failure) { + 'header' => ['headers' => ['Authorization' => 'Bearer secret', 'X-Invalid' => "invalid\0value"]], + 'uri' => ['baseURI' => 'https://private.example:invalid/'], + 'cert' => ['cert' => 'missing-certificate.pem'], + 'verify' => ['verify' => 'missing-ca-bundle.pem'], + default => [], + }; + + $request->setOutput(match ($failure) { + 'response header' => "HTTP/1.1 200 OK\r\nX-Invalid: invalid\0value\r\n\r\nprivate body", + 'response status' => "HTTP/1.1 999 Invalid\r\n\r\nprivate body", + default => '', + }); + + $exception = null; + + try { + $request->post('https://private.example/upload', $options); + } catch (Throwable $e) { + $exception = $e; + } + + $expectedException = in_array($failure, ['header', 'response header'], true) + ? InvalidArgumentException::class + : HTTPException::class; + + $this->assertInstanceOf($expectedException, $exception); + $this->assertNull($request->header('Authorization')); + $this->assertNull($request->getBody()); + + $request->setOutput(''); + $request->get('https://other.example/'); + + $options = $request->curl_options; + + $this->assertSame('https://other.example/', $options[CURLOPT_URL]); + $this->assertArrayNotHasKey(CURLOPT_HTTPHEADER, $options); + $this->assertArrayNotHasKey(CURLOPT_POSTFIELDS, $options); + $this->assertArrayNotHasKey(CURLOPT_USERPWD, $options); + } + + /** + * @return iterable + */ + public static function provideFailureResetsSetterOptions(): iterable + { + foreach (['header', 'uri', 'cert', 'verify', 'transport', 'response header', 'response status'] as $failure) { + foreach (['auth', 'body', 'form', 'multipart', 'json'] as $setter) { + yield $failure . ' with ' . $setter => [$failure, $setter]; + } + } + } + + #[DataProvider('provideRequestRestoresBaseURIAndDelay')] + public function testRequestRestoresBaseURIAndDelay(bool $fail, string $defaults): void + { + $this->configureSharing(false); + + $baseURI = 'https://default.example/api/?default=value'; + $options = $defaults === 'options' ? ['baseURI' => $baseURI, 'delay' => 100] : []; + $uri = $defaults === 'uri' ? new URI($baseURI) : new URI(); + $app = new App(); + $request = $fail + ? $this->failingRequest($options, $uri) + : new MockCURLRequest($app, $uri, new Response($app), $options); + + try { + $request->get('reports', [ + 'baseURI' => 'https://alice@private.example:8443/v2/?api_key=secret#private', + 'delay' => 200, + ]); + $this->assertFalse($fail, 'Expected a transport exception.'); + } catch (HTTPException $e) { + $this->assertTrue($fail); + $this->assertStringContainsString('simulated transport failure', $e->getMessage()); + } + + $expectedURI = $defaults === 'none' ? (string) new URI() : $baseURI; + $this->assertSame($expectedURI, (string) $request->getBaseURI()); + $this->assertEqualsWithDelta($defaults === 'options' ? 0.1 : 0.0, $request->getDelay(), PHP_FLOAT_EPSILON); + + // Mutating the restored URI must not mutate the saved constructor defaults. + $request->get('reports', ['baseURI' => 'https://bob@private.example:9443/v3/?token=secret']); + $this->assertSame($expectedURI, (string) $request->getBaseURI()); + + $request->get('', ['baseURI' => 'https://other.example/']); + + $expectedURL = $defaults === 'none' ? 'https://other.example/' : 'https://other.example/?default=value'; + $this->assertSame($expectedURL, $request->curl_options[CURLOPT_URL]); + $this->assertSame($expectedURI, (string) $request->getBaseURI()); + } + + /** + * @return iterable + */ + public static function provideRequestRestoresBaseURIAndDelay(): iterable + { + foreach ([false, true] as $fail) { + foreach (['none', 'uri', 'options'] as $defaults) { + yield ($fail ? 'failure' : 'success') . ' with ' . $defaults => [$fail, $defaults]; + } + } + } + + #[DataProvider('provideSharingPreservesBaseURIAndDelay')] + public function testSharingPreservesBaseURIAndDelay(bool $fail): void + { + $this->configureSharing(true); + + $app = new App(); + $request = $fail + ? $this->failingRequest() + : new MockCURLRequest($app, new URI(), new Response($app)); + $baseURI = 'https://alice@private.example:8443/v2/?api_key=secret'; + + try { + $request->get('reports', ['baseURI' => $baseURI, 'delay' => 200]); + $this->assertFalse($fail, 'Expected a transport exception.'); + } catch (HTTPException $e) { + $this->assertTrue($fail); + $this->assertStringContainsString('simulated transport failure', $e->getMessage()); + } + + $this->assertSame($baseURI, (string) $request->getBaseURI()); + $this->assertEqualsWithDelta(0.2, $request->getDelay(), PHP_FLOAT_EPSILON); + } + + /** + * @return iterable + */ + public static function provideSharingPreservesBaseURIAndDelay(): iterable + { + yield 'success' => [false]; + + yield 'failure' => [true]; + } + + public function testTransportFailureResetsRequestOptions(): void + { + $this->configureSharing(false); + + $request = $this->failingRequest([ + 'headers' => ['X-Default' => 'configured'], + 'user_agent' => 'DefaultAgent', + ]); + + try { + $request->post('https://private.example/upload', [ + 'headers' => ['Authorization' => 'Bearer secret'], + 'body' => 'private body', + 'auth' => ['user', 'password'], + 'proxy' => 'http://proxy.example:3128', + ]); + $this->fail('Expected a transport exception.'); + } catch (HTTPException $e) { + $this->assertStringContainsString('simulated transport failure', $e->getMessage()); + } + + $this->assertNull($request->header('Authorization')); + $this->assertNull($request->getBody()); + + $request->get('https://other.example/'); + + $options = $request->curl_options; + + $this->assertSame('https://other.example/', $options[CURLOPT_URL]); + $this->assertSame(['X-Default: configured'], $options[CURLOPT_HTTPHEADER]); + $this->assertSame('DefaultAgent', $options[CURLOPT_USERAGENT]); + $this->assertArrayNotHasKey(CURLOPT_POSTFIELDS, $options); + $this->assertArrayNotHasKey(CURLOPT_USERPWD, $options); + $this->assertArrayNotHasKey(CURLOPT_PROXY, $options); + } + + public function testOptionValidationFailureResetsRequestOptions(): void + { + $this->configureSharing(false); + + $app = new App(); + $request = new MockCURLRequest($app, new URI(), new Response($app)); + + try { + $request->post('https://private.example/upload', [ + 'headers' => ['Authorization' => 'Bearer secret'], + 'body' => 'private body', + 'cert' => 'missing-certificate.pem', + ]); + $this->fail('Expected an invalid certificate exception.'); + } catch (HTTPException $e) { + $this->assertStringContainsString('missing-certificate.pem', $e->getMessage()); + } + + $this->assertNull($request->header('Authorization')); + $this->assertNull($request->getBody()); + + $request->get('https://other.example/'); + + $options = $request->curl_options; + + $this->assertArrayNotHasKey(CURLOPT_HTTPHEADER, $options); + $this->assertArrayNotHasKey(CURLOPT_POSTFIELDS, $options); + } + + public function testTransportFailureKeepsOptionsWhenSharingEnabled(): void + { + $this->configureSharing(true); + + $request = $this->failingRequest(); + + try { + $request->post('https://private.example/upload', [ + 'headers' => ['Authorization' => 'Bearer secret'], + 'body' => 'private body', + ]); + $this->fail('Expected a transport exception.'); + } catch (HTTPException $e) { + $this->assertStringContainsString('simulated transport failure', $e->getMessage()); + } + + $request->get('https://other.example/'); + + $options = $request->curl_options; + + $this->assertSame(['Authorization: Bearer secret'], $options[CURLOPT_HTTPHEADER]); + $this->assertSame('private body', $options[CURLOPT_POSTFIELDS]); + } + + /** + * @param array $options + */ + private function failingRequest(array $options = [], ?URI $uri = null): MockCURLRequest + { + $app = new App(); + + return new class ($app, $uri ?? new URI(), new Response($app), $options) extends MockCURLRequest { + private bool $failNextRequest = true; + + protected function sendRequest(array $curlOptions = []): string + { + if ($this->failNextRequest) { + $this->failNextRequest = false; + + throw HTTPException::forCurlError('7', 'simulated transport failure'); + } + + return parent::sendRequest($curlOptions); + } + }; + } + + private function configureSharing(bool $shareOptions): void + { + $config = new ConfigCURLRequest(); + $config->shareOptions = $shareOptions; + + Factories::injectMock('config', 'CURLRequest', $config); + } +} diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index 9fac71493b39..f0260ff839c9 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -39,9 +39,9 @@ class CURLRequestTest extends CIUnitTestCase protected function setUp(): void { + $this->resetServices(); parent::setUp(); - $this->resetServices(); Services::injectMock('superglobals', new Superglobals()); $this->request = $this->getRequest(); } @@ -1566,4 +1566,103 @@ public function testProxyAndContinueResponses(): void $this->assertSame($testBody, $response->getBody()); } + + public function testSetCURLOptions(): void + { + $invoker = self::getPrivateMethodInvoker($this->request, 'setCURLOptions'); + + // Test Auth Options + $options = $invoker([], ['auth' => ['user', 'pass', 'digest']]); + $this->assertSame('user:pass', $options[CURLOPT_USERPWD]); + $this->assertSame(CURLAUTH_DIGEST, $options[CURLOPT_HTTPAUTH]); + + $options2 = $invoker([], ['auth' => ['user', 'pass', 'basic']]); + $this->assertSame(CURLAUTH_BASIC, $options2[CURLOPT_HTTPAUTH]); + + // Test SSL Options + $options3 = $invoker([], ['cert' => __FILE__]); + $this->assertSame(__FILE__, $options3[CURLOPT_SSLCERT]); + + $options4 = $invoker([], ['verify' => false]); + $this->assertFalse($options4[CURLOPT_SSL_VERIFYPEER]); + $this->assertSame(0, $options4[CURLOPT_SSL_VERIFYHOST]); + + // Test Proxy Options + $options5 = $invoker([], ['proxy' => 'http://proxy.example.com']); + $this->assertTrue($options5[CURLOPT_HTTPPROXYTUNNEL]); + $this->assertSame('http://proxy.example.com', $options5[CURLOPT_PROXY]); + + // Test Debug Options + $options6 = $invoker([], ['debug' => true]); + $this->assertSame(1, $options6[CURLOPT_VERBOSE]); + $this->assertIsResource($options6[CURLOPT_STDERR]); + + // Test Redirect Options + $options7 = $invoker([], ['allow_redirects' => false]); + $this->assertSame(0, $options7[CURLOPT_FOLLOWLOCATION]); + + $options8 = $invoker([], ['allow_redirects' => true]); + $this->assertSame(1, $options8[CURLOPT_FOLLOWLOCATION]); + $this->assertSame(5, $options8[CURLOPT_MAXREDIRS]); + + // Test Connection Options + $options9 = $invoker([], [ + 'dns_cache_timeout' => 120, + 'fresh_connect' => false, + 'timeout' => 10, + 'connect_timeout' => 5, + 'force_ip_resolve' => 'v4', + ]); + $this->assertSame(120, $options9[CURLOPT_DNS_CACHE_TIMEOUT]); + $this->assertFalse($options9[CURLOPT_FRESH_CONNECT]); + $this->assertEqualsWithDelta(10000.0, $options9[CURLOPT_TIMEOUT_MS], PHP_FLOAT_EPSILON); + $this->assertEqualsWithDelta(5000.0, $options9[CURLOPT_CONNECTTIMEOUT_MS], PHP_FLOAT_EPSILON); + $this->assertSame(CURL_IPRESOLVE_V4, $options9[CURLOPT_IPRESOLVE]); + + // Test Body Options (form_params / multipart / json) + $options10 = $invoker([], ['form_params' => ['foo' => 'bar']]); + $this->assertSame('foo=bar', $options10[CURLOPT_POSTFIELDS]); + + $options11 = $invoker([], ['multipart' => ['file' => 'data']]); + $this->assertSame(['file' => 'data'], $options11[CURLOPT_POSTFIELDS]); + + // Test Response Options (decode_content / http_errors) + $options12 = $invoker([], ['decode_content' => true]); + $this->assertSame('', $options12[CURLOPT_ENCODING]); + $this->assertSame('Accept-Encoding', $options12[CURLOPT_HTTPHEADER]); + + // Test Protocol/Misc/Client Options + $options13 = $invoker([], [ + 'http_errors' => false, + 'version' => '2.0', + 'cookie' => 'cookies.txt', + 'user_agent' => 'TestAgent', + ]); + $this->assertFalse($options13[CURLOPT_FAILONERROR]); + $this->assertSame(CURL_HTTP_VERSION_2_0, $options13[CURLOPT_HTTP_VERSION]); + $this->assertSame('cookies.txt', $options13[CURLOPT_COOKIEJAR]); + $this->assertSame('cookies.txt', $options13[CURLOPT_COOKIEFILE]); + $this->assertSame('TestAgent', $options13[CURLOPT_USERAGENT]); + } + + public function testCURLOptionsPreservesIntegerKeys(): void + { + // cURL options use integer constants as keys. This test ensures they are not re-indexed. + $request = $this->getRequest(); + $method = self::getPrivateMethodInvoker($request, 'setCURLOptions'); + + $initialOptions = [ + CURLOPT_RETURNTRANSFER => true, + ]; + + $config = [ + 'auth' => ['user', 'pass'], + ]; + + $options = $method($initialOptions, $config); + + // Verify keys are preserved and not re-indexed to 0, 1... + $this->assertArrayHasKey(CURLOPT_RETURNTRANSFER, $options); + $this->assertArrayHasKey(CURLOPT_USERPWD, $options); + } } diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 5bced590228a..780cb99f31ea 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\TestResponse; @@ -90,15 +91,68 @@ public function testExistence(): void { $this->assertTrue($this->work()); $this->assertHeaderEmitted('Content-Security-Policy:'); + $this->assertHeaderNotEmitted('Content-Security-Policy-Report-Only:'); + $this->assertHeaderNotEmitted('Reporting-Endpoints:'); } #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testReportOnly(): void { - $this->csp->reportOnly(false); + $config = new CSPConfig(); + $config->reportOnly = true; + + Services::injectMock('csp', new ContentSecurityPolicy($config)); + + $this->response = new Response(config(App::class)); + $this->response->pretend(false); + $this->csp = $this->response->getCSP(); + $this->assertTrue($this->work()); - $this->assertHeaderEmitted('Content-Security-Policy:'); + $this->assertHeaderEmitted('Content-Security-Policy-Report-Only:'); + $this->assertHeaderNotEmitted('Content-Security-Policy:'); + $this->assertHeaderNotEmitted('Reporting-Endpoints:'); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingCSPHeaderStringIsPreserved(): void + { + $this->response->setHeader('Content-Security-Policy', "frame-src 'none'"); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy:'); + $this->assertIsString($header); + $this->assertStringContainsString("frame-src 'none', base-uri 'self'", $header); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingCSPHeaderArrayIsPreserved(): void + { + $this->response->setHeader('Content-Security-Policy', [ + "frame-src 'none'", + "media-src 'none'", + ]); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy:'); + $this->assertIsString($header); + $this->assertStringContainsString("frame-src 'none', media-src 'none', base-uri 'self'", $header); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingReportOnlyHeaderIsPreserved(): void + { + $this->response->setHeader('Content-Security-Policy-Report-Only', "frame-src 'none'"); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy-Report-Only:'); + $this->assertSame("Content-Security-Policy-Report-Only: frame-src 'none'", $header); } #[PreserveGlobalState(false)] @@ -609,6 +663,23 @@ public function testReportTo(): void $this->assertSame('Reporting-Endpoints: default="http://example.com/csp-reports"', $header); } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingReportingEndpointsHeaderIsPreserved(): void + { + $this->response->setHeader('Reporting-Endpoints', 'existing="http://example.com/existing-reports"'); + $this->csp->addReportingEndpoints(['default' => 'http://example.com/csp-reports']); + $this->csp->setReportToEndpoint('default'); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Reporting-Endpoints:'); + $this->assertSame( + 'Reporting-Endpoints: existing="http://example.com/existing-reports", default="http://example.com/csp-reports"', + $header, + ); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testReportToMultipleEndpoints(): void diff --git a/tests/system/HTTP/CorsTest.php b/tests/system/HTTP/CorsTest.php index 5373959746ac..0da2bdcee55b 100644 --- a/tests/system/HTTP/CorsTest.php +++ b/tests/system/HTTP/CorsTest.php @@ -14,14 +14,22 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[BackupGlobals(true)] #[Group('Others')] final class CorsTest extends CIUnitTestCase { + protected function setUp(): void + { + $this->resetServices(); + parent::setUp(); + } + /** * @param array{ * allowedOrigins?: list, diff --git a/tests/system/HTTP/DownloadResponseTest.php b/tests/system/HTTP/DownloadResponseTest.php index d7cfcf937612..d5fea35e380c 100644 --- a/tests/system/HTTP/DownloadResponseTest.php +++ b/tests/system/HTTP/DownloadResponseTest.php @@ -20,6 +20,7 @@ use CodeIgniter\Test\CIUnitTestCase; use DateTime; use DateTimeZone; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -28,6 +29,7 @@ /** * @internal */ +#[BackupGlobals(true)] #[Group('SeparateProcess')] final class DownloadResponseTest extends CIUnitTestCase { diff --git a/tests/system/HTTP/Files/FileMovingTest.php b/tests/system/HTTP/Files/FileMovingTest.php index 3284c33d0f26..fb07d10ed74b 100644 --- a/tests/system/HTTP/Files/FileMovingTest.php +++ b/tests/system/HTTP/Files/FileMovingTest.php @@ -98,6 +98,7 @@ public function testMove(): void $this->assertTrue($this->root->hasChild('destination/' . $finalFilename . '.txt')); $this->assertTrue($this->root->hasChild('destination/' . $finalFilename . '_1.txt')); + $this->assertSame(0666 & ~umask(), $this->root->getChild('destination/' . $finalFilename . '.txt')->getPermissions()); } public function testMoveSanitizesClientNameByDefault(): void @@ -366,7 +367,7 @@ public function testFailedMoveBecauseOfFalseReturned(): void * * This overwrite is for testing the move operation. */ -function is_uploaded_file($filename): bool +function is_uploaded_file(string $filename): bool { if (! is_file($filename)) { file_put_contents($filename, 'data'); @@ -380,7 +381,7 @@ function is_uploaded_file($filename): bool * * This overwrite is for testing the move operation. */ -function move_uploaded_file($filename, $destination, ?bool $setReturnValue = null): bool +function move_uploaded_file(string $filename, string $destination, ?bool $setReturnValue = null): bool { static $return = true; @@ -396,7 +397,7 @@ function move_uploaded_file($filename, $destination, ?bool $setReturnValue = nul return $return; } -function rrmdir($src): void +function rrmdir(string $src): void { $dir = opendir($src); diff --git a/tests/system/HTTP/HeaderTest.php b/tests/system/HTTP/HeaderTest.php index b7a1eff0e150..3d79da95f09b 100644 --- a/tests/system/HTTP/HeaderTest.php +++ b/tests/system/HTTP/HeaderTest.php @@ -67,7 +67,7 @@ public function testHeaderStoresBasicWithObject(): void $name = 'foo'; $value = new stdClass(); - new Header($name, $value); + new Header($name, $value); // @phpstan-ignore argument.type } public function testHeaderStoresArrayValues(): void @@ -275,7 +275,7 @@ public static function provideInvalidHeaderNames(): iterable } /** - * @param array|string>|string|null $value + * @param array|string>|string|null $value */ #[DataProvider('provideInvalidHeaderValues')] public function testInvalidHeaderValues($value): void @@ -286,7 +286,7 @@ public function testInvalidHeaderValues($value): void } /** - * @return list|string>> + * @return list|string>> */ public static function provideInvalidHeaderValues(): iterable { diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 2634e31a4b84..464b46e2ce2c 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -29,7 +29,6 @@ use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\WithoutErrorHandler; -use TypeError; /** * @internal @@ -38,11 +37,12 @@ #[Group('SeparateProcess')] final class IncomingRequestTest extends CIUnitTestCase { - private Request $request; + private IncomingRequest $request; #[WithoutErrorHandler] protected function setUp(): void { + $this->resetServices(); parent::setUp(); $_ENV = $_SESSION = []; @@ -58,7 +58,7 @@ protected function setUp(): void $this->request = $this->createRequest($config); } - private function createRequest(?App $config = null, $body = null, ?string $path = null): IncomingRequest + private function createRequest(?App $config = null, false|string|null $body = null, ?string $path = null): IncomingRequest { $config ??= new App(); $path ??= ''; @@ -280,7 +280,7 @@ public function testSetValidLocales(): void */ public function testNegotiatesLocale(): void { - service('superglobals')->setServer('HTTP_ACCEPT_LANGUAGE', 'fr-FR); q=1.0, en; q=0.5'); + service('superglobals')->setServer('HTTP_ACCEPT_LANGUAGE', 'fr-FR; q=1.0, en; q=0.5'); $config = new App(); $config->negotiateLocale = true; @@ -295,7 +295,7 @@ public function testNegotiatesLocale(): void public function testNegotiatesLocaleOnlyBroad(): void { - service('superglobals')->setServer('HTTP_ACCEPT_LANGUAGE', 'fr); q=1.0, en; q=0.5'); + service('superglobals')->setServer('HTTP_ACCEPT_LANGUAGE', 'fr; q=1.0, en; q=0.5'); $config = new App(); $config->negotiateLocale = true; @@ -349,7 +349,7 @@ public function testNegotiatesEncoding(): void public function testNegotiatesLanguage(): void { $this->request->setHeader('Accept-Language', 'da, en-gb;q=0.8, en;q=0.7'); - $this->assertSame('en', $this->request->negotiate('language', ['en', 'da'])); + $this->assertSame('da', $this->request->negotiate('language', ['en', 'da'])); } public function testCanGrabGetRawJSON(): void @@ -634,6 +634,9 @@ public function testCanGrabGetRawInputVar($rawstring, $var, $expected, $filter, $this->assertSame($expected, $request->getRawInputVar($var, $filter, $flag)); } + /** + * @return iterable + */ public static function provideCanGrabGetRawInputVar(): iterable { return [ @@ -728,6 +731,9 @@ public function testIsHTTPMethodLowerCase(string $value): void $this->assertTrue($request->is(strtolower($value))); } + /** + * @return iterable + */ public static function provideIsHTTPMethods(): iterable { yield from [ @@ -755,7 +761,6 @@ public function testIsInvalidValue(): void $this->expectExceptionMessage('Unknown type: invalid'); $request = $this->request->withMethod('GET'); - $request->is('invalid'); } @@ -763,6 +768,7 @@ public function testIsJson(): void { $request = $this->request->setHeader('Content-Type', 'application/json'); + $this->assertInstanceOf(IncomingRequest::class, $request); $this->assertTrue($request->is('json')); } @@ -770,6 +776,7 @@ public function testIsWithAjax(): void { $request = $this->request->setHeader('X-Requested-With', 'XMLHttpRequest'); + $this->assertInstanceOf(IncomingRequest::class, $request); $this->assertTrue($request->is('ajax')); } @@ -949,6 +956,7 @@ public function testGetFile(): void ]); $gotit = $this->request->getFile('userfile'); + $this->assertInstanceOf(UploadedFile::class, $gotit); $this->assertSame(124, $gotit->getSize()); } @@ -1046,12 +1054,13 @@ public function testGetIPAddressNormal(): void $expected = '123.123.123.123'; service('superglobals')->setServer('REMOTE_ADDR', $expected); - $this->request = new Request(new App()); - $this->request->populateHeaders(); + $request = new Request(new App()); + $request->populateHeaders(); - $this->assertSame($expected, $this->request->getIPAddress()); + $ipAddress = $request->getIPAddress(); + $this->assertSame($expected, $ipAddress); // call a second time to exercise the initial conditional block in getIPAddress() - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxy(): void @@ -1067,11 +1076,11 @@ public function testGetIPAddressThruProxy(): void '192.168.5.0/24' => 'X-Forwarded-For', ]; Factories::injectMock('config', App::class, $config); - $this->request = new Request(); - $this->request->populateHeaders(); + $request = new Request(); + $request->populateHeaders(); // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyIPv6(): void @@ -1086,11 +1095,11 @@ public function testGetIPAddressThruProxyIPv6(): void '2001:db8::2:1' => 'X-Forwarded-For', ]; Factories::injectMock('config', App::class, $config); - $this->request = new Request(); - $this->request->populateHeaders(); + $request = new Request(); + $request->populateHeaders(); // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyInvalidIPAddress(): void @@ -1104,11 +1113,11 @@ public function testGetIPAddressThruProxyInvalidIPAddress(): void '10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For', ]; - $this->request = new Request($config); - $this->request->populateHeaders(); + $request = new Request($config); + $request->populateHeaders(); // spoofed address invalid - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyInvalidIPAddressIPv6(): void @@ -1121,11 +1130,11 @@ public function testGetIPAddressThruProxyInvalidIPAddressIPv6(): void $config->proxyIPs = [ '2001:db8::2:1' => 'X-Forwarded-For', ]; - $this->request = new Request($config); - $this->request->populateHeaders(); + $request = new Request($config); + $request->populateHeaders(); // spoofed address invalid - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyNotWhitelisted(): void @@ -1139,11 +1148,11 @@ public function testGetIPAddressThruProxyNotWhitelisted(): void '10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For', ]; - $this->request = new Request($config); - $this->request->populateHeaders(); + $request = new Request($config); + $request->populateHeaders(); // spoofed address invalid - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyNotWhitelistedIPv6(): void @@ -1156,11 +1165,11 @@ public function testGetIPAddressThruProxyNotWhitelistedIPv6(): void $config->proxyIPs = [ '2001:db8::2:1' => 'X-Forwarded-For', ]; - $this->request = new Request($config); - $this->request->populateHeaders(); + $request = new Request($config); + $request->populateHeaders(); // spoofed address invalid - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxySubnet(): void @@ -1172,11 +1181,11 @@ public function testGetIPAddressThruProxySubnet(): void $config = new App(); $config->proxyIPs = ['192.168.5.0/24' => 'X-Forwarded-For']; Factories::injectMock('config', App::class, $config); - $this->request = new Request(); - $this->request->populateHeaders(); + $request = new Request(); + $request->populateHeaders(); // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxySubnetIPv6(): void @@ -1188,11 +1197,11 @@ public function testGetIPAddressThruProxySubnetIPv6(): void $config = new App(); $config->proxyIPs = ['2001:db8:1234::/48' => 'X-Forwarded-For']; Factories::injectMock('config', App::class, $config); - $this->request = new Request(); - $this->request->populateHeaders(); + $request = new Request(); + $request->populateHeaders(); // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyOutOfSubnet(): void @@ -1203,11 +1212,11 @@ public function testGetIPAddressThruProxyOutOfSubnet(): void $config = new App(); $config->proxyIPs = ['192.168.5.0/28' => 'X-Forwarded-For']; - $this->request = new Request($config); - $this->request->populateHeaders(); + $request = new Request($config); + $request->populateHeaders(); // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyOutOfSubnetIPv6(): void @@ -1218,11 +1227,11 @@ public function testGetIPAddressThruProxyOutOfSubnetIPv6(): void $config = new App(); $config->proxyIPs = ['2001:db8:1234::/48' => 'X-Forwarded-For']; - $this->request = new Request($config); - $this->request->populateHeaders(); + $request = new Request($config); + $request->populateHeaders(); // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyBothIPv4AndIPv6(): void @@ -1236,23 +1245,11 @@ public function testGetIPAddressThruProxyBothIPv4AndIPv6(): void '192.168.5.0/28' => 'X-Forwarded-For', '2001:db8:1234::/48' => 'X-Forwarded-For', ]; - $this->request = new Request($config); - $this->request->populateHeaders(); + $request = new Request($config); + $request->populateHeaders(); // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); - } - - public function testGetIPAddressThruProxyInvalidConfigString(): void - { - $this->expectException(TypeError::class); - - $config = new App(); - $config->proxyIPs = '192.168.5.0/28'; - $this->request = new Request($config); - $this->request->populateHeaders(); - - $this->request->getIPAddress(); + $this->assertSame($expected, $request->getIPAddress()); } public function testGetIPAddressThruProxyInvalidConfigArray(): void @@ -1263,12 +1260,12 @@ public function testGetIPAddressThruProxyInvalidConfigArray(): void ); $config = new App(); - $config->proxyIPs = ['192.168.5.0/28']; + $config->proxyIPs = ['192.168.5.0/28']; // @phpstan-ignore assign.propertyType (deliberately keyless, to assert the ConfigException) Factories::injectMock('config', App::class, $config); - $this->request = new Request(); - $this->request->populateHeaders(); + $request = new Request(); + $request->populateHeaders(); - $this->request->getIPAddress(); + $request->getIPAddress(); } // @TODO getIPAddress should have more testing, to 100% code coverage diff --git a/tests/system/HTTP/MessageTest.php b/tests/system/HTTP/MessageTest.php index 7a691af39c08..f921795c2a48 100644 --- a/tests/system/HTTP/MessageTest.php +++ b/tests/system/HTTP/MessageTest.php @@ -196,7 +196,7 @@ public function testSetHeaderArrayValues(): void } /** - * @param array $arrayHeaderValue + * @param list $arrayHeaderValue */ #[DataProvider('provideArrayHeaderValue')] public function testSetHeaderWithExistingArrayValuesAppendStringValue($arrayHeaderValue): void @@ -208,7 +208,7 @@ public function testSetHeaderWithExistingArrayValuesAppendStringValue($arrayHead } /** - * @param array $arrayHeaderValue + * @param list $arrayHeaderValue */ #[DataProvider('provideArrayHeaderValue')] public function testSetHeaderWithExistingArrayValuesAppendArrayValue($arrayHeaderValue): void @@ -219,6 +219,9 @@ public function testSetHeaderWithExistingArrayValuesAppendArrayValue($arrayHeade $this->assertSame('json, html, xml', $this->message->getHeaderLine('Accept')); } + /** + * @return iterable}> + */ public static function provideArrayHeaderValue(): iterable { return [ @@ -259,7 +262,11 @@ public function testPopulateHeadersWithoutContentType(): void $this->assertNull($this->message->header('content-type')); - putenv("CONTENT_TYPE={$originalEnv}"); + if ($originalEnv !== false) { + putenv("CONTENT_TYPE={$originalEnv}"); + } else { + putenv('CONTENT_TYPE'); + } } public function testPopulateHeadersWithoutHTTP(): void diff --git a/tests/system/HTTP/NegotiateTest.php b/tests/system/HTTP/NegotiateTest.php index e52f6f7a3201..083846c8a1a8 100644 --- a/tests/system/HTTP/NegotiateTest.php +++ b/tests/system/HTTP/NegotiateTest.php @@ -17,11 +17,13 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\App; use Config\Feature; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[BackupGlobals(true)] #[Group('Others')] final class NegotiateTest extends CIUnitTestCase { @@ -30,6 +32,7 @@ final class NegotiateTest extends CIUnitTestCase protected function setUp(): void { + $this->resetServices(); parent::setUp(); $config = new App(); diff --git a/tests/system/HTTP/RedirectResponseTest.php b/tests/system/HTTP/RedirectResponseTest.php index 2ea700c03d88..bd597078f720 100644 --- a/tests/system/HTTP/RedirectResponseTest.php +++ b/tests/system/HTTP/RedirectResponseTest.php @@ -24,6 +24,7 @@ use Config\Modules; use Config\Routing; use Config\Services; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -32,23 +33,18 @@ /** * @internal */ +#[BackupGlobals(true)] #[Group('SeparateProcess')] final class RedirectResponseTest extends CIUnitTestCase { - /** - * @var RouteCollection - */ - protected $routes; - private MockIncomingRequest $request; private App $config; #[WithoutErrorHandler] protected function setUp(): void { - parent::setUp(); - $this->resetServices(); + parent::setUp(); Services::injectMock('superglobals', new Superglobals()); service('superglobals')->setServer('REQUEST_METHOD', 'GET'); diff --git a/tests/system/HTTP/RequestTest.php b/tests/system/HTTP/RequestTest.php index eb15506e6fdf..2dafdc4fd792 100644 --- a/tests/system/HTTP/RequestTest.php +++ b/tests/system/HTTP/RequestTest.php @@ -195,7 +195,6 @@ public function testFetchGlobalReturnsArrayValues(): void $result = $this->request->fetchGlobal('post'); $this->assertSame($post, $result); - $this->assertIsArray($result['ANNOUNCEMENTS']); $this->assertCount(2, $result['ANNOUNCEMENTS']); } @@ -563,7 +562,8 @@ public function testGetIPAddressNormal(): void $expected = '123.123.123.123'; service('superglobals')->setServer('REMOTE_ADDR', $expected); $this->request = new Request(new App()); - $this->assertSame($expected, $this->request->getIPAddress()); + $ipAddress = $this->request->getIPAddress(); + $this->assertSame($expected, $ipAddress); // call a second time to exercise the initial conditional block in getIPAddress() $this->assertSame($expected, $this->request->getIPAddress()); } diff --git a/tests/system/HTTP/ResponseCookieTest.php b/tests/system/HTTP/ResponseCookieTest.php index 6fb03d516a9f..55a832a3581d 100644 --- a/tests/system/HTTP/ResponseCookieTest.php +++ b/tests/system/HTTP/ResponseCookieTest.php @@ -28,6 +28,9 @@ #[Group('Others')] final class ResponseCookieTest extends CIUnitTestCase { + /** + * @var array + */ private array $defaults; protected function setUp(): void diff --git a/tests/system/HTTP/ResponseTest.php b/tests/system/HTTP/ResponseTest.php index 17d1b4191134..939b29b148f5 100644 --- a/tests/system/HTTP/ResponseTest.php +++ b/tests/system/HTTP/ResponseTest.php @@ -22,25 +22,29 @@ use Config\App; use DateTime; use DateTimeZone; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[BackupGlobals(true)] #[Group('Others')] final class ResponseTest extends CIUnitTestCase { + /** + * @var array + */ private array $server; protected function setUp(): void { - Services::injectMock('superglobals', new Superglobals()); - $this->server = service('superglobals')->getServerArray(); - + $this->resetServices(); parent::setUp(); - $this->resetServices(); + Services::injectMock('superglobals', new Superglobals()); + $this->server = service('superglobals')->getServerArray(); } protected function tearDown(): void @@ -169,6 +173,7 @@ public function testSetLink(): void Factories::injectMock('config', 'App', $config); $this->resetServices(); + Services::injectMock('superglobals', new Superglobals([], [])); $response = new Response($config); $pager = service('pager'); @@ -294,6 +299,9 @@ public function testRedirect( $this->assertSame($expectedCode, $response->getStatusCode()); } + /** + * @return iterable + */ public static function provideRedirect(): iterable { yield from [ @@ -339,6 +347,9 @@ public function testRedirectWithIIS( service('superglobals')->unsetServer('SERVER_SOFTWARE'); } + /** + * @return iterable + */ public static function provideRedirectWithIIS(): iterable { yield from [ diff --git a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php index b864e493bb5f..4bc29fbecc4d 100644 --- a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php +++ b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php @@ -37,6 +37,9 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); } + /** + * @param array $server + */ private function createSiteURIFactory(array $server, ?App $appConfig = null): SiteURIFactory { $appConfig ??= new App(); @@ -316,6 +319,9 @@ public function testExtensionPHP($path, $detectPath): void $this->assertSame($detectPath, $factory->detectRoutePath()); } + /** + * @return iterable + */ public static function provideExtensionPHP(): iterable { return [ diff --git a/tests/system/HTTP/SiteURIFactoryTest.php b/tests/system/HTTP/SiteURIFactoryTest.php index 8057c7826d62..5c062371a3ad 100644 --- a/tests/system/HTTP/SiteURIFactoryTest.php +++ b/tests/system/HTTP/SiteURIFactoryTest.php @@ -106,6 +106,9 @@ public function testCreateFromStringWithIndexPage( $this->assertSame($expectedRoutePath, $uri->getRoutePath()); } + /** + * @return iterable + */ public static function provideCreateFromStringWithIndexPage(): iterable { return [ @@ -149,6 +152,9 @@ public function testCreateFromStringWithoutIndexPage( $this->assertSame($expectedRoutePath, $uri->getRoutePath()); } + /** + * @return iterable + */ public static function provideCreateFromStringWithoutIndexPage(): iterable { return [ diff --git a/tests/system/HTTP/SiteURITest.php b/tests/system/HTTP/SiteURITest.php index 1208edaf05b9..d4f0f6f66c62 100644 --- a/tests/system/HTTP/SiteURITest.php +++ b/tests/system/HTTP/SiteURITest.php @@ -29,6 +29,9 @@ #[Group('Others')] final class SiteURITest extends CIUnitTestCase { + /** + * @param list $expectedSegments + */ #[DataProvider('provideConstructor')] public function testConstructor( string $baseURL, @@ -61,11 +64,17 @@ public function testConstructor( $this->assertSame($expectedTotalSegments, $uri->getTotalSegments()); } + /** + * @return iterable, int}> + */ public static function provideConstructor(): iterable { return array_merge(self::provideSetPath(), self::provideRelativePathWithQueryOrFragment()); } + /** + * @return iterable + */ public static function provideRelativePathWithQueryOrFragment(): iterable { return [ @@ -177,6 +186,9 @@ public function testConstructorInvalidBaseURL(): void new SiteURI($config); } + /** + * @param list $expectedSegments + */ #[DataProvider('provideSetPath')] public function testSetPath( string $baseURL, @@ -209,6 +221,9 @@ public function testSetPath( $this->assertSame($expectedTotalSegments, $uri->getTotalSegments()); } + /** + * @return iterable, int}> + */ public static function provideSetPath(): iterable { return [ diff --git a/tests/system/HTTP/URITest.php b/tests/system/HTTP/URITest.php index c449d13af65c..273f6cba1095 100644 --- a/tests/system/HTTP/URITest.php +++ b/tests/system/HTTP/URITest.php @@ -137,6 +137,9 @@ public function testSimpleUri(string $url, string $expectedURL, string $expected $this->assertSame($expectedPath, $uri->getPath()); } + /** + * @return iterable + */ public static function provideSimpleUri(): iterable { return [ @@ -412,6 +415,9 @@ public function testSetPath(string $path, string $expectedURL, string $expectedP $this->assertSame($expectedPath, $uri->getPath()); } + /** + * @return iterable + */ public static function provideSetPath(): iterable { return [ @@ -481,6 +487,9 @@ public function testPathGetsFiltered($path, $expected): void $this->assertSame($expected, $uri->getPath()); } + /** + * @return iterable + */ public static function providePathGetsFiltered(): iterable { return [ @@ -615,6 +624,9 @@ public function testAuthorityReturnsExceptedValues($url, $expected): void $this->assertSame($expected, $uri->getAuthority()); } + /** + * @return iterable + */ public static function provideAuthorityReturnsExceptedValues(): iterable { return [ @@ -667,6 +679,9 @@ public function testAuthorityRemovesDefaultPorts($scheme, $port): void $this->assertSame($expected, (string) $uri); } + /** + * @return iterable + */ public static function provideAuthorityRemovesDefaultPorts(): iterable { return [ @@ -701,6 +716,9 @@ public function testRemoveDotSegments($path, $expected): void $this->assertSame($expected, URI::removeDotSegments($path)); } + /** + * @return iterable + */ public static function provideRemoveDotSegments(): iterable { return [ @@ -831,6 +849,9 @@ public function testResolveRelativeURIHTTPS($rel, $expected): void $this->assertSame($expected, (string) $new); } + /** + * @return iterable + */ public static function defaultResolutions(): iterable { return [ @@ -1071,7 +1092,10 @@ public function testBasedNoIndex(): void (string) $request->getUri(), ); $this->assertSame('/ci/v4/controller/method', $request->getUri()->getPath()); - $this->assertSame('controller/method', $request->getUri()->getRoutePath()); + + $requestUri = $request->getUri(); + $this->assertInstanceOf(SiteURI::class, $requestUri); + $this->assertSame('controller/method', $requestUri->getRoutePath()); // standalone $uri = new URI('http://example.com/ci/v4/controller/method'); @@ -1192,7 +1216,7 @@ public function testSetURISilent(): void $uri->setSilent()->setURI($url); - $this->assertTrue(true); + $this->expectNotToPerformAssertions(); } public function testCreateURIStringNoArguments(): void diff --git a/tests/system/HTTP/UserAgentTest.php b/tests/system/HTTP/UserAgentTest.php index b19e0c62dee5..cc6947d1dd50 100644 --- a/tests/system/HTTP/UserAgentTest.php +++ b/tests/system/HTTP/UserAgentTest.php @@ -14,11 +14,13 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[BackupGlobals(true)] #[Group('Others')] final class UserAgentTest extends CIUnitTestCase { @@ -28,6 +30,7 @@ final class UserAgentTest extends CIUnitTestCase protected function setUp(): void { + $this->resetServices(); parent::setUp(); // set a baseline user agent diff --git a/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php b/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php index 0641c41b2bd0..432fafcf1a0c 100644 --- a/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php +++ b/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php @@ -23,6 +23,9 @@ #[Group('Others')] final class ArrayHelperDotKeyExistsTest extends CIUnitTestCase { + /** + * @var array>>> + */ private array $array = [ 'contacts' => [ 'friends' => [ diff --git a/tests/system/Helpers/Array/ArrayHelperRecursiveDiffTest.php b/tests/system/Helpers/Array/ArrayHelperRecursiveDiffTest.php index 2bb1c594def1..9c3e6f943604 100644 --- a/tests/system/Helpers/Array/ArrayHelperRecursiveDiffTest.php +++ b/tests/system/Helpers/Array/ArrayHelperRecursiveDiffTest.php @@ -22,6 +22,9 @@ #[Group('Others')] final class ArrayHelperRecursiveDiffTest extends CIUnitTestCase { + /** + * @var array + */ private array $compareWith; protected function setUp(): void diff --git a/tests/system/Helpers/Array/ArrayHelperSortValuesByNaturalTest.php b/tests/system/Helpers/Array/ArrayHelperSortValuesByNaturalTest.php index 4529b4da6918..e3c38c9030fa 100644 --- a/tests/system/Helpers/Array/ArrayHelperSortValuesByNaturalTest.php +++ b/tests/system/Helpers/Array/ArrayHelperSortValuesByNaturalTest.php @@ -22,6 +22,9 @@ #[Group('Others')] final class ArrayHelperSortValuesByNaturalTest extends CIUnitTestCase { + /** + * @var list + */ private array $arrayWithStringValues = [ 'apple10', 'banana', @@ -37,6 +40,10 @@ final class ArrayHelperSortValuesByNaturalTest extends CIUnitTestCase 'Яблоко', 'apple', ]; + + /** + * @var list> + */ private array $arrayWithArrayValues = [ ['apple', 'Banana'], ['apple10', 'Apple'], diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index 0293cc6c826e..e91d8e56cc8e 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -211,11 +211,10 @@ public function testArrayDotIgnoresLastWildcard(): void } /** - * @param int|string $key - * @param array|string|null $expected + * @param array|string|null $expected */ #[DataProvider('provideArrayDeepSearch')] - public function testArrayDeepSearch($key, $expected): void + public function testArrayDeepSearch(int|string $key, array|string|null $expected): void { $data = [ 'key1' => 'Value 1', @@ -241,6 +240,9 @@ public function testArrayDeepSearch($key, $expected): void $this->assertSame($expected, $result); } + /** + * @return iterable|string|null}> + */ public static function provideArrayDeepSearch(): iterable { return [ @@ -274,6 +276,11 @@ public function testArrayDeepSearchReturnNullEmptyArray(): void $this->assertNull(array_deep_search('key644', $data)); } + /** + * @param list $data + * @param array $sortColumns + * @param list $expected + */ #[DataProvider('provideSortByMultipleKeys')] public function testArraySortByMultipleKeysWithArray(array $data, array $sortColumns, array $expected): void { @@ -283,6 +290,11 @@ public function testArraySortByMultipleKeysWithArray(array $data, array $sortCol $this->assertSame($expected, array_column($data, 'name')); } + /** + * @param list $data + * @param array $sortColumns + * @param list $expected + */ #[DataProvider('provideSortByMultipleKeys')] public function testArraySortByMultipleKeysWithObjects(array $data, array $sortColumns, array $expected): void { @@ -297,6 +309,11 @@ public function testArraySortByMultipleKeysWithObjects(array $data, array $sortC $this->assertSame($expected, array_column($data, 'name')); } + /** + * @param list $data + * @param array $sortColumns + * @param list $expected + */ #[DataProvider('provideSortByMultipleKeys')] public function testArraySortByMultipleKeysFailsEmptyParameter(array $data, array $sortColumns, array $expected): void { @@ -334,6 +351,9 @@ public function testArraySortByMultipleKeysFailsInconsistentArraySizes($data): v array_sort_by_multiple_keys($data, $sortColumns); } + /** + * @return iterable, array, list}> + */ public static function provideSortByMultipleKeys(): iterable { $seed = [ @@ -387,12 +407,19 @@ public static function provideSortByMultipleKeys(): iterable ]; } + /** + * @param array $input + * @param array $expected + */ #[DataProvider('provideArrayFlattening')] public function testArrayFlattening(array $input, array $expected): void { $this->assertSame($expected, array_flatten_with_dots($input)); } + /** + * @return iterable, array}> + */ public static function provideArrayFlattening(): iterable { yield 'normal' => [ @@ -485,6 +512,11 @@ public static function provideArrayFlattening(): iterable ]; } + /** + * @param list $indexes + * @param array $data + * @param array $expected + */ #[DataProvider('provideArrayGroupByIncludeEmpty')] public function testArrayGroupByIncludeEmpty(array $indexes, array $data, array $expected): void { @@ -493,6 +525,9 @@ public function testArrayGroupByIncludeEmpty(array $indexes, array $data, array $this->assertSame($expected, $actual, 'array including empty not the same'); } + /** + * @return iterable, array, array}> + */ public static function provideArrayGroupByIncludeEmpty(): iterable { yield 'simple group-by test' => [ @@ -904,6 +939,11 @@ public static function provideArrayGroupByIncludeEmpty(): iterable ]; } + /** + * @param list $indexes + * @param array $data + * @param array $expected + */ #[DataProvider('provideArrayGroupByExcludeEmpty')] public function testArrayGroupByExcludeEmpty(array $indexes, array $data, array $expected): void { @@ -912,6 +952,9 @@ public function testArrayGroupByExcludeEmpty(array $indexes, array $data, array $this->assertSame($expected, $actual, 'array excluding empty not the same'); } + /** + * @return iterable, array, array}> + */ public static function provideArrayGroupByExcludeEmpty(): iterable { yield 'simple group-by test' => [ diff --git a/tests/system/Helpers/CookieHelperTest.php b/tests/system/Helpers/CookieHelperTest.php index 9b79e8d61dbc..f853d8ec7820 100644 --- a/tests/system/Helpers/CookieHelperTest.php +++ b/tests/system/Helpers/CookieHelperTest.php @@ -51,9 +51,11 @@ protected function setUp(): void $this->value = 'hello world'; $this->expire = 9999; - Services::injectMock('response', new MockResponse(new App())); - $this->response = service('response'); - $request = new IncomingRequest(new App(), new SiteURI(new App()), null, new UserAgent()); + $response = new MockResponse(new App()); + Services::injectMock('response', $response); + $this->response = $response; + + $request = new IncomingRequest(new App(), new SiteURI(new App()), null, new UserAgent()); Services::injectMock('request', $request); helper('cookie'); diff --git a/tests/system/Helpers/FilesystemHelperTest.php b/tests/system/Helpers/FilesystemHelperTest.php index a8ee76d6ab09..f264864ebf9a 100644 --- a/tests/system/Helpers/FilesystemHelperTest.php +++ b/tests/system/Helpers/FilesystemHelperTest.php @@ -188,13 +188,17 @@ public function testDirectoryMirrorSkipExistingFolder(): void // skips the existing folder directory_mirror($root . 'src', $root . 'dest'); - $structure = vfsStream::inspect(new vfsStreamStructureVisitor())->getStructure(); + $visitor = vfsStream::inspect(new vfsStreamStructureVisitor()); + $this->assertInstanceOf(vfsStreamStructureVisitor::class, $visitor); + $structure = $visitor->getStructure(); $this->assertSame([], $structure['root']['dest']['AnEmptyFolder']); // skips the existing folder (the same as overwrite = true) directory_mirror($root . 'src', $root . 'dest', false); - $structure = vfsStream::inspect(new vfsStreamStructureVisitor())->getStructure(); + $visitor = vfsStream::inspect(new vfsStreamStructureVisitor()); + $this->assertInstanceOf(vfsStreamStructureVisitor::class, $visitor); + $structure = $visitor->getStructure(); $this->assertSame([], $structure['root']['dest']['AnEmptyFolder']); } @@ -477,6 +481,22 @@ public function testGetDirFileInfoFailure(): void $this->assertSame($expected, get_dir_file_info(SUPPORTPATH . 'Files#baker')); } + public function testGetDirFileInfoIgnoresDirectoriesWhenTopLevelOnlyIsTrue(): void + { + $dir = WRITEPATH . 'test_get_dir_file_info'; + mkdir($dir . '/subdir', 0777, true); + file_put_contents($dir . '/file.txt', 'test'); + + $result = get_dir_file_info($dir, true); + + unlink($dir . '/file.txt'); + rmdir($dir . '/subdir'); + rmdir($dir); + + $this->assertArrayHasKey('file.txt', $result); + $this->assertArrayNotHasKey('subdir', $result); + } + public function testGetFileInfo(): void { $file = SUPPORTPATH . 'Files/baker/banana.php'; diff --git a/tests/system/Helpers/FormHelperTest.php b/tests/system/Helpers/FormHelperTest.php index 73cbe2430a5f..c24d34b04a3e 100644 --- a/tests/system/Helpers/FormHelperTest.php +++ b/tests/system/Helpers/FormHelperTest.php @@ -870,7 +870,8 @@ public function testSetValue(): void public function testSetSelect(): void { $_SESSION['_ci_old_input']['post']['foo'] = 'bar'; - $this->assertSame(' selected="selected"', set_select('foo', 'bar')); + $selected = set_select('foo', 'bar'); + $this->assertSame(' selected="selected"', $selected); $_SESSION['_ci_old_input']['post']['foo'] = ['foo' => 'bar']; $this->assertSame(' selected="selected"', set_select('foo', 'bar')); @@ -890,7 +891,8 @@ public function testSetCheckbox(): void ], ]; - $this->assertSame(' checked="checked"', set_checkbox('foo', 'bar')); + $checked = set_checkbox('foo', 'bar'); + $this->assertSame(' checked="checked"', $checked); $_SESSION = [ '_ci_old_input' => [ @@ -919,7 +921,8 @@ public function testSetCheckboxWithValueZero(): void ], ]; - $this->assertSame(' checked="checked"', set_checkbox('foo', '0')); + $checked = set_checkbox('foo', '0'); + $this->assertSame(' checked="checked"', $checked); $_SESSION = [ '_ci_old_input' => [ diff --git a/tests/system/Helpers/HTMLHelperTest.php b/tests/system/Helpers/HTMLHelperTest.php index dcce366474b9..787b100bbf2b 100644 --- a/tests/system/Helpers/HTMLHelperTest.php +++ b/tests/system/Helpers/HTMLHelperTest.php @@ -26,6 +26,9 @@ #[Group('Others')] final class HTMLHelperTest extends CIUnitTestCase { + /** + * @var list + */ private array $tracks; /** diff --git a/tests/system/Helpers/InflectorHelperTest.php b/tests/system/Helpers/InflectorHelperTest.php index 59068b8bf751..3d3079ec0880 100644 --- a/tests/system/Helpers/InflectorHelperTest.php +++ b/tests/system/Helpers/InflectorHelperTest.php @@ -252,6 +252,9 @@ public function testOrdinal(string $suffix, int $number): void $this->assertSame($suffix, ordinal($number)); } + /** + * @return iterable + */ public static function provideOrdinal(): iterable { return [ diff --git a/tests/system/Helpers/TextHelperTest.php b/tests/system/Helpers/TextHelperTest.php index a14c722816ae..88878bcf8294 100644 --- a/tests/system/Helpers/TextHelperTest.php +++ b/tests/system/Helpers/TextHelperTest.php @@ -130,8 +130,7 @@ public function testRandomString(): void $this->assertSame(16, strlen(random_string('numeric', 16))); $this->assertSame(8, strlen(random_string('numeric'))); - $this->assertSame(16, strlen($random = random_string('crypto', 16))); - $this->assertIsString($random); + $this->assertSame(16, strlen(random_string('crypto', 16))); } /** @@ -162,7 +161,8 @@ public function testIncrementString(): void $this->assertSame('my-test_1', increment_string('my-test')); $this->assertSame('my-test-1', increment_string('my-test', '-')); $this->assertSame('file_5', increment_string('file_4')); - $this->assertSame('file-5', increment_string('file-4', '-')); + $incremented = increment_string('file-4', '-'); + $this->assertSame('file-5', $incremented); $this->assertSame('file-5', increment_string('file-4', '-')); $this->assertSame('file-1', increment_string('file', '-', 1)); $this->assertSame('124', increment_string('123', '')); @@ -358,6 +358,27 @@ public function testEllipsize(): void } } + /** + * A character limit below 2 used to make word_wrap() loop forever. + */ + #[DataProvider('provideWordWrapSmallCharLimit')] + public function testWordWrapSmallCharLimit(int $charlim): void + { + $this->assertSame("aaaa\nbbbb", word_wrap('aaaa bbbb', $charlim)); + } + + /** + * @return iterable + */ + public static function provideWordWrapSmallCharLimit(): iterable + { + yield 'one' => [1]; + + yield 'zero' => [0]; + + yield 'negative' => [-5]; + } + public function testWordWrap(): void { $string = 'Here is a simple string of text that will help us demonstrate this function.'; diff --git a/tests/system/Helpers/URLHelper/CurrentUrlTest.php b/tests/system/Helpers/URLHelper/CurrentUrlTest.php index 54ac56bb6a54..6058ba1698d2 100644 --- a/tests/system/Helpers/URLHelper/CurrentUrlTest.php +++ b/tests/system/Helpers/URLHelper/CurrentUrlTest.php @@ -89,7 +89,7 @@ public function testCurrentURLReturnsAllowedHostname(): void $this->assertSame('http://www.example.jp/public/index.php/', current_url()); } - private function createRequest(?App $config = null, $body = null, ?string $path = null): void + private function createRequest(?App $config = null, ?string $body = null, ?string $path = null): void { $config ??= new App(); @@ -281,6 +281,9 @@ public function testUrlIsWithSubfolder(string $currentPath, string $testPath, bo $this->assertSame($expected, url_is($testPath)); } + /** + * @return iterable + */ public static function provideUrlIs(): iterable { return [ diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index 422b2e01cea6..c8cefbda9c8b 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -146,6 +146,9 @@ public function testAnchor($expected = '', $uri = '', $title = '', $attributes = // Test anchor + /** + * @return iterable> + */ public static function provideAnchor(): iterable { return [ @@ -206,6 +209,9 @@ public function testAnchorNoindex($expected = '', $uri = '', $title = '', $attri $this->assertSame($expected, anchor($uri, $title, $attributes, $this->config)); } + /** + * @return iterable> + */ public static function provideAnchorNoindex(): iterable { return [ @@ -272,6 +278,9 @@ public function testAnchorTargetted($expected = '', $uri = '', $title = '', $att $this->assertSame($expected, anchor($uri, $title, $attributes, $this->config)); } + /** + * @return iterable> + */ public static function provideAnchorTargetted(): iterable { return [ @@ -326,6 +335,9 @@ public function testAnchorExamples($expected = '', $uri = '', $title = '', $attr $this->assertSame($expected, anchor($uri, $title, $attributes, $this->config)); } + /** + * @return iterable> + */ public static function provideAnchorExamples(): iterable { return [ @@ -371,6 +383,9 @@ public function testAnchorPopup($expected = '', $uri = '', $title = '', $attribu // Test anchor_popup + /** + * @return iterable> + */ public static function provideAnchorPopup(): iterable { return [ @@ -428,6 +443,9 @@ public function testMailto($expected = '', $email = '', $title = '', $attributes // Test mailto + /** + * @return iterable> + */ public static function provideMailto(): iterable { return [ @@ -466,6 +484,9 @@ public function testSafeMailto($expected = '', $email = '', $title = '', $attrib // Test safe_mailto + /** + * @return iterable> + */ public static function provideSafeMailto(): iterable { return [ @@ -509,6 +530,9 @@ public function testAutoLinkUrl($in, $out): void // Test auto_link + /** + * @return iterable + */ public static function provideAutoLinkUrl(): iterable { return [ @@ -561,6 +585,9 @@ public function testAutoLinkEmail($in, $out): void $this->assertSame($out, auto_link($in, 'email')); } + /** + * @return iterable + */ public static function provideAutoLinkEmail(): iterable { return [ @@ -609,6 +636,9 @@ public function testAutolinkBoth($in, $out): void $this->assertSame($out, auto_link($in)); } + /** + * @return iterable + */ public static function provideAutolinkBoth(): iterable { return [ @@ -661,6 +691,9 @@ public function testAutoLinkPopup($in, $out): void $this->assertSame($out, auto_link($in, 'url', true)); } + /** + * @return iterable + */ public static function provideAutoLinkPopup(): iterable { return [ @@ -707,6 +740,9 @@ public function testPrepUrl(string $input, string $expected, bool $secure): void // Test prep_url + /** + * @return iterable + */ public static function providePrepUrl(): iterable { // input, expected, secure @@ -843,7 +879,7 @@ public function testMbUrlTitleExtraDashes(): void } #[DataProvider('provideUrlTo')] - public function testUrlTo(string $expected, string $input, ...$args): void + public function testUrlTo(string $expected, string $input, int|string ...$args): void { service('superglobals')->setServer('HTTP_HOST', 'example.com'); @@ -856,6 +892,9 @@ public function testUrlTo(string $expected, string $input, ...$args): void $this->assertSame($expected, url_to($input, ...$args)); } + /** + * @return iterable + */ public static function provideUrlTo(): iterable { $page = config('App')->indexPage !== '' ? config('App')->indexPage . '/' : ''; @@ -884,6 +923,9 @@ public function testUrlToThrowsOnEmptyOrMissingRoute(string $route): void url_to($route); } + /** + * @return iterable + */ public static function provideUrlToThrowsOnEmptyOrMissingRoute(): iterable { return [ diff --git a/tests/system/Helpers/URLHelper/SiteUrlCliTest.php b/tests/system/Helpers/URLHelper/SiteUrlCliTest.php index ad99f46a8b2a..0d9d9b114dd8 100644 --- a/tests/system/Helpers/URLHelper/SiteUrlCliTest.php +++ b/tests/system/Helpers/URLHelper/SiteUrlCliTest.php @@ -94,6 +94,9 @@ public function testUrls( $this->assertSame($expectedBaseUrl, base_url($path, $scheme)); } + /** + * @return iterable + */ public static function provideUrls(): iterable { // baseURL, indexPage, scheme, secure, path, expectedSiteUrl, expectedBaseUrl diff --git a/tests/system/Helpers/URLHelper/SiteUrlTest.php b/tests/system/Helpers/URLHelper/SiteUrlTest.php index c8a07758e3bd..a095cf703a86 100644 --- a/tests/system/Helpers/URLHelper/SiteUrlTest.php +++ b/tests/system/Helpers/URLHelper/SiteUrlTest.php @@ -55,7 +55,7 @@ protected function tearDown(): void service('superglobals')->setServerArray([]); } - private function createRequest(?App $config = null, $body = null, ?string $path = null): void + private function createRequest(?App $config = null, ?string $body = null, ?string $path = null): void { $config ??= new App(); @@ -105,6 +105,9 @@ public function testUrls( $this->assertSame($expectedBaseUrl, base_url($path, $scheme)); } + /** + * @return iterable + */ public static function provideUrls(): iterable { // baseURL, indexPage, scheme, secure, path, expectedSiteUrl, expectedBaseUrl diff --git a/tests/system/Honeypot/HoneypotTest.php b/tests/system/Honeypot/HoneypotTest.php index 12341f645889..0f0ba51f9d52 100644 --- a/tests/system/Honeypot/HoneypotTest.php +++ b/tests/system/Honeypot/HoneypotTest.php @@ -61,8 +61,11 @@ protected function setUp(): void $superglobals->setServer('REQUEST_METHOD', 'POST'); $superglobals->setPost($this->config->name, 'hey'); - $this->request = service('request', null, false); - $this->response = service('response'); + $this->request = service('request', null, false); + + $response = service('response'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; } public function testAttachHoneypot(): void @@ -106,7 +109,10 @@ public function testAttachHoneypotAndContainerWithCSP(): void $config = new App(); $config->CSPEnabled = true; Factories::injectMock('config', 'App', $config); - $this->response = service('response', $config, false); + + $response = service('response', $config, false); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $this->config = new HoneypotConfig(); $this->honeypot = new Honeypot($this->config); @@ -125,7 +131,10 @@ public function testNotAttachHoneypotWithCSP(): void $config = new App(); $config->CSPEnabled = true; Factories::injectMock('config', 'App', $config); - $this->response = service('response', $config, false); + + $response = service('response', $config, false); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; $this->config = new HoneypotConfig(); $this->honeypot = new Honeypot($this->config); @@ -153,6 +162,7 @@ public function testConfigTemplate(): void { $this->config->template = ''; $this->expectException(HoneypotException::class); + $this->expectExceptionCode(500); $this->honeypot = new Honeypot($this->config); } @@ -160,6 +170,7 @@ public function testConfigName(): void { $this->config->name = ''; $this->expectException(HoneypotException::class); + $this->expectExceptionCode(500); $this->honeypot = new Honeypot($this->config); } @@ -173,6 +184,7 @@ public function testHoneypotFilterBefore(): void $uri = 'admin/foo/bar'; $this->expectException(HoneypotException::class); + $this->expectExceptionCode(403); $filters->run($uri, 'before'); } @@ -186,7 +198,11 @@ public function testHoneypotFilterAfter(): void $uri = 'admin/foo/bar'; $this->response->setBody('
'); - $this->response = $filters->run($uri, 'after'); + + $response = $filters->run($uri, 'after'); + $this->assertInstanceOf(Response::class, $response); + $this->response = $response; + $this->assertStringContainsString($this->config->name, (string) $this->response->getBody()); } diff --git a/tests/system/HotReloader/DirectoryHasherTest.php b/tests/system/HotReloader/DirectoryHasherTest.php index a8a112ffe84b..8a0428465cea 100644 --- a/tests/system/HotReloader/DirectoryHasherTest.php +++ b/tests/system/HotReloader/DirectoryHasherTest.php @@ -62,7 +62,6 @@ public function testHashApp(): void $results = $this->hasher->hashApp(); - $this->assertIsArray($results); $this->assertArrayHasKey($this->fixtureDirectory, $results); } diff --git a/tests/system/I18n/TimeLegacyTest.php b/tests/system/I18n/TimeLegacyTest.php index 8fbd359fbfd5..ec6cdb042640 100644 --- a/tests/system/I18n/TimeLegacyTest.php +++ b/tests/system/I18n/TimeLegacyTest.php @@ -170,9 +170,14 @@ public function testToday(): void public function testTodayLocalized(): void { + // Freeze time to avoid flakiness when server and London are on different calendar days + TimeLegacy::setTestNow('2026-06-15 12:00:00', 'UTC'); + $time = TimeLegacy::today('Europe/London'); - $this->assertSame(date('Y-m-d 00:00:00'), $time->toDateTimeString()); + $this->assertSame('2026-06-15 00:00:00', $time->toDateTimeString()); + + TimeLegacy::setTestNow(); } public function testYesterday(): void @@ -317,7 +322,7 @@ public function testMagicIssetFalse(): void { $time = TimeLegacy::parse('January 1, 2016'); - $this->assertFalse(isset($time->foobar)); + $this->assertFalse(isset($time->foobar)); // @phpstan-ignore property.notFound } public function testGetYear(): void @@ -1128,7 +1133,7 @@ public function testGetter(): void { $time = TimeLegacy::parse('August 12, 2016 4:15:23pm'); - $this->assertNull($time->weekOfWeek); + $this->assertNull($time->weekOfWeek); // @phpstan-ignore property.notFound } public function testUnserializeTimeObject(): void @@ -1163,6 +1168,9 @@ public function testToStringDoesNotDependOnLocale(string $locale): void $this->assertSame('2017-03-10 12:00:00', (string) $time); } + /** + * @return iterable + */ public static function provideToStringDoesNotDependOnLocale(): iterable { yield from [ diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index a7346c181b93..3368e5802d54 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -171,9 +171,14 @@ public function testToday(): void public function testTodayLocalized(): void { + // Freeze time to avoid flakiness when server and London are on different calendar days + Time::setTestNow('2026-06-15 12:00:00', 'UTC'); + $time = Time::today('Europe/London'); - $this->assertSame(date('Y-m-d 00:00:00'), $time->toDateTimeString()); + $this->assertSame('2026-06-15 00:00:00', $time->toDateTimeString()); + + Time::setTestNow(); } public function testYesterday(): void @@ -190,6 +195,65 @@ public function testTomorrow(): void $this->assertSame(date('Y-m-d 00:00:00', strtotime('+1 day')), $time->toDateTimeString()); } + public function testTodayWithTimezoneAcrossDateBoundary(): void + { + // When UTC is 2026-09-07 23:30:00, in Asia/Tokyo (+09:00) it is already 2026-09-08 08:30:00 + Time::setTestNow('2026-09-07 23:30:00', 'UTC'); + + $tokyoToday = Time::today('Asia/Tokyo'); + $this->assertSame('2026-09-08 00:00:00', $tokyoToday->toDateTimeString()); + + // When UTC is 2026-09-08 02:00:00, in America/New_York (-04:00 EDT) it is still 2026-09-07 22:00:00 + Time::setTestNow('2026-09-08 02:00:00', 'UTC'); + + $nyToday = Time::today('America/New_York'); + $this->assertSame('2026-09-07 00:00:00', $nyToday->toDateTimeString()); + + Time::setTestNow(); + } + + public function testYesterdayWithTimezoneAcrossDateBoundary(): void + { + // When UTC is 2026-09-07 23:30:00, in Tokyo it is 2026-09-08, so Tokyo yesterday is 2026-09-07 + Time::setTestNow('2026-09-07 23:30:00', 'UTC'); + + $tokyoYesterday = Time::yesterday('Asia/Tokyo'); + $this->assertSame('2026-09-07 00:00:00', $tokyoYesterday->toDateTimeString()); + + // When UTC is 2026-09-08 02:00:00, in NY it is 2026-09-07, so NY yesterday is 2026-09-06 + Time::setTestNow('2026-09-08 02:00:00', 'UTC'); + + $nyYesterday = Time::yesterday('America/New_York'); + $this->assertSame('2026-09-06 00:00:00', $nyYesterday->toDateTimeString()); + // March 28 at 23:30 does not exist in Nuuk due to the DST transition. + Time::setTestNow('2026-03-29 23:30:00', 'America/Nuuk'); + + $nuukYesterday = Time::yesterday('America/Nuuk'); + $this->assertSame('2026-03-28 00:00:00', $nuukYesterday->toDateTimeString()); + Time::setTestNow(); + } + + public function testTomorrowWithTimezoneAcrossDateBoundary(): void + { + // When UTC is 2026-09-07 23:30:00, in Tokyo it is 2026-09-08, so Tokyo tomorrow is 2026-09-09 + Time::setTestNow('2026-09-07 23:30:00', 'UTC'); + + $tokyoTomorrow = Time::tomorrow('Asia/Tokyo'); + $this->assertSame('2026-09-09 00:00:00', $tokyoTomorrow->toDateTimeString()); + + // When UTC is 2026-09-08 02:00:00, in NY it is 2026-09-07, so NY tomorrow is 2026-09-08 + Time::setTestNow('2026-09-08 02:00:00', 'UTC'); + + $nyTomorrow = Time::tomorrow('America/New_York'); + $this->assertSame('2026-09-08 00:00:00', $nyTomorrow->toDateTimeString()); + // Moving forward must select March 28 without normalizing into March 29. + Time::setTestNow('2026-03-27 23:30:00', 'America/Nuuk'); + + $nuukTomorrow = Time::tomorrow('America/Nuuk'); + $this->assertSame('2026-03-28 00:00:00', $nuukTomorrow->toDateTimeString()); + Time::setTestNow(); + } + public function testCreateFromDate(): void { $time = Time::createFromDate(2017, 03, 05, 'America/Chicago'); @@ -290,7 +354,7 @@ public function testCreateFromTimestamp(): void public function testCreateFromTimestampWithMicroseconds(): void { - $timestamp = 1489762800.654321; + $timestamp = 1_489_762_800.654321; $locale = setlocale(LC_NUMERIC, '0'); setlocale(LC_NUMERIC, 'de_DE.UTF-8', 'de_DE'); @@ -346,7 +410,7 @@ public function testMagicIssetFalse(): void { $time = Time::parse('January 1, 2016'); - $this->assertFalse(isset($time->foobar)); + $this->assertFalse(isset($time->foobar)); // @phpstan-ignore property.notFound } public function testGetYear(): void @@ -1280,7 +1344,7 @@ public function testGetter(): void { $time = Time::parse('August 12, 2016 4:15:23pm'); - $this->assertNull($time->weekOfWeek); + $this->assertNull($time->weekOfWeek); // @phpstan-ignore property.notFound } public function testUnserializeTimeObject(): void @@ -1324,6 +1388,9 @@ public function testToStringDoesNotDependOnLocale(string $locale): void $this->assertSame('2017-03-10 12:00:00', (string) $time); } + /** + * @return iterable + */ public static function provideToStringDoesNotDependOnLocale(): iterable { yield from [ diff --git a/tests/system/Images/GDHandlerTest.php b/tests/system/Images/GDHandlerTest.php index d85d1a142ffd..a121b17ec899 100644 --- a/tests/system/Images/GDHandlerTest.php +++ b/tests/system/Images/GDHandlerTest.php @@ -19,7 +19,9 @@ use CodeIgniter\Test\CIUnitTestCase; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; +use org\bovigo\vfs\vfsStreamFile; use PHPUnit\Framework\Attributes\Group; +use Throwable; /** * Unit testing for the GD image handler. @@ -344,9 +346,12 @@ public function testImageCopy(): void $this->handler->save($this->start . 'work/ci-logo.' . $type); $this->assertTrue($this->root->hasChild('work/ci-logo.' . $type)); + /** @var vfsStreamFile $child */ + $child = $this->root->getChild('work/ci-logo.' . $type); + $this->assertNotSame( file_get_contents($this->origin . 'ci-logo.' . $type), - $this->root->getChild('work/ci-logo.' . $type)->getContent(), + $child->getContent(), ); } } @@ -378,9 +383,12 @@ public function testImageCompressionGetResource(): void $this->handler->save($this->start . 'work/ci-logo.' . $type); $this->assertTrue($this->root->hasChild('work/ci-logo.' . $type)); + /** @var vfsStreamFile $child */ + $child = $this->root->getChild('work/ci-logo.' . $type); + $this->assertNotSame( file_get_contents($this->origin . 'ci-logo.' . $type), - $this->root->getChild('work/ci-logo.' . $type)->getContent(), + $child->getContent(), ); } } @@ -399,9 +407,12 @@ public function testImageCompressionWithResource(): void $this->assertTrue($this->root->hasChild('work/ci-logo.' . $type)); + /** @var vfsStreamFile $child */ + $child = $this->root->getChild('work/ci-logo.' . $type); + $this->assertNotSame( file_get_contents($this->origin . 'ci-logo.' . $type), - $this->root->getChild('work/ci-logo.' . $type)->getContent(), + $child->getContent(), ); } } @@ -463,4 +474,56 @@ public function testClearMetadataReturnsSelf(): void $this->assertSame($this->handler, $result); } + + public function testSaveFailed(): void + { + foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + if ($type === 'webp' && ! function_exists('imagecreatefromwebp')) { + $this->markTestSkipped('webp is not supported.'); + } + + $this->handler->withFile($this->origin . 'ci-logo.' . $type); + $this->handler->getResource(); // make sure resource is loaded + + try { + $this->handler->save($this->start . 'wontwork/ci-logo.' . $type); + $this->fail('Exception should have been thrown for ' . $type); + } catch (Throwable $e) { + $this->assertInstanceOf(ImageException::class, $e); + $this->assertSame(lang('Images.saveFailed'), $e->getMessage()); + } + } + } + + public function testImageCopyTargetWithMaxQuality(): void + { + foreach (['gif', 'jpeg', 'png', 'webp'] as $type) { + $this->handler->withFile($this->origin . 'ci-logo.' . $type); + $this->assertTrue($this->handler->save($this->start . 'work/ci-logo.' . $type, 100)); + $this->assertTrue($this->root->hasChild('work/ci-logo.' . $type)); + + /** @var vfsStreamFile $child */ + $child = $this->root->getChild('work/ci-logo.' . $type); + + $this->assertSame( + file_get_contents($this->origin . 'ci-logo.' . $type), + $child->getContent(), + ); + } + } + + public function testProcessUnsupportedImageType(): void + { + $this->handler->withFile($this->path); + + // Force an invalid image type to trigger getImageResource() default case + $image = $this->handler->getFile(); + $image->imageType = 9999; + + $this->expectException(ImageException::class); + $this->expectExceptionMessage(lang('Images.unsupportedImageCreate')); + + // resize() calls ensureResource() -> getImageResource(), which should throw + $this->handler->resize(10, 10); + } } diff --git a/tests/system/Language/LanguageTest.php b/tests/system/Language/LanguageTest.php index 6233941e7d75..b67292da2236 100644 --- a/tests/system/Language/LanguageTest.php +++ b/tests/system/Language/LanguageTest.php @@ -338,10 +338,12 @@ public function testLangKeepLocale(): void $lang = service('language', 'en', true); lang('Language.languageGetLineInvalidArgumentException'); - $this->assertSame('en', $lang->getLocale()); + $locale = $lang->getLocale(); + $this->assertSame('en', $locale); lang('Language.languageGetLineInvalidArgumentException', [], 'ru'); - $this->assertSame('en', $lang->getLocale()); + $locale = $lang->getLocale(); + $this->assertSame('en', $locale); lang('Language.languageGetLineInvalidArgumentException'); $this->assertSame('en', $lang->getLocale()); diff --git a/tests/system/Log/LoggerTest.php b/tests/system/Log/LoggerTest.php index 80d42d6c83b3..bbdf304acf8c 100644 --- a/tests/system/Log/LoggerTest.php +++ b/tests/system/Log/LoggerTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Log; +use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\FrameworkException; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\I18n\Time; @@ -22,6 +23,8 @@ use PHPUnit\Framework\Attributes\Group; use ReflectionMethod; use ReflectionNamedType; +use stdClass; +use Stringable; use Tests\Support\Log\Handlers\TestHandler; /** @@ -123,6 +126,161 @@ public function testLogInterpolatesMessage(): void $this->assertSame($expected, $logs[0]); } + public function testLogInterpolatesArrayContextValue(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $payload = ['id' => 123, 'status' => 'created']; + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message ' . print_r($payload, true); + + $logger->log('debug', 'Test message {payload}', ['payload' => $payload]); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testLogInterpolatesOnlyExactDottedContextKey(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message literal'; + + $logger->log('debug', 'Test message {foo.bar}', [ + 'foo.bar' => 'literal', + 'foo' => ['bar' => 'nested'], + ]); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testLogIgnoresUnusedArrayContextValueDuringInterpolation(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message value'; + + $logger->log('debug', 'Test message {name}', [ + 'name' => 'value', + 'payload' => ['id' => 123], + ]); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testLogInterpolatesStringableContextValue(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message stringable'; + + $logger->log('debug', 'Test message {value}', [ + 'value' => new class () implements Stringable { + public function __toString(): string + { + return 'stringable'; + } + }, + ]); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testLogInterpolatesTimeContextValue(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $time = Time::parse('2024-01-02 03:04:05', 'UTC'); + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message 2024-01-02 03:04:05'; + + $logger->log('debug', 'Test message {time}', ['time' => $time]); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testLogInterpolatesNonStringableObjectContextValue(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message [object stdClass]'; + + $logger->log('debug', 'Test message {value}', ['value' => new stdClass()]); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testLogInterpolatesResourceContextValue(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $resource = fopen('php://memory', 'rb'); + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message [resource (stream)]'; + + $logger->log('debug', 'Test message {value}', ['value' => $resource]); + + fclose($resource); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + + public function testLogInterpolatesEntityContextValue(): void + { + $config = new LoggerConfig(); + $logger = new Logger($config); + + Time::setTestNow('2023-11-25 12:00:00'); + + $entity = new Entity(['id' => 123, 'name' => 'Ada']); + $expected = 'DEBUG - ' . Time::now()->format('Y-m-d') . ' --> Test message ' . print_r($entity->toArray(), true); + + $logger->log('debug', 'Test message {entity}', ['entity' => $entity]); + + $logs = TestHandler::getLogs(); + + $this->assertCount(1, $logs); + $this->assertSame($expected, $logs[0]); + } + public function testLogInterpolatesPost(): void { $config = new LoggerConfig(); diff --git a/tests/system/Models/EventsModelTest.php b/tests/system/Models/EventsModelTest.php index 4c6b2640723a..82e49952c7a2 100644 --- a/tests/system/Models/EventsModelTest.php +++ b/tests/system/Models/EventsModelTest.php @@ -23,17 +23,19 @@ #[Group('DatabaseLive')] final class EventsModelTest extends LiveModelTestCase { - /** - * @var EventModel - */ - protected $model; - protected function setUp(): void { parent::setUp(); $this->createModel(EventModel::class); } + private function model(): EventModel + { + $this->assertInstanceOf(EventModel::class, $this->model); + + return $this->model; + } + public function testInsertEvent(): void { $data = [ @@ -43,9 +45,9 @@ public function testInsertEvent(): void 'deleted' => 0, ]; - $this->model->insert($data); - $this->assertTrue($this->model->hasToken('beforeInsert')); - $this->assertTrue($this->model->hasToken('afterInsert')); + $this->model()->insert($data); + $this->assertTrue($this->model()->hasToken('beforeInsert')); + $this->assertTrue($this->model()->hasToken('afterInsert')); } public function testUpdateEvent(): void @@ -57,106 +59,106 @@ public function testUpdateEvent(): void 'deleted' => 0, ]; - $id = $this->model->insert($data); + $id = $this->model()->insert($data); - $this->model->update($id, $data); - $this->assertTrue($this->model->hasToken('beforeUpdate')); - $this->assertTrue($this->model->hasToken('afterUpdate')); + $this->model()->update($id, $data); + $this->assertTrue($this->model()->hasToken('beforeUpdate')); + $this->assertTrue($this->model()->hasToken('afterUpdate')); } public function testDeleteEvent(): void { - $this->model->delete(1); - $this->assertTrue($this->model->hasToken('beforeDelete')); - $this->assertTrue($this->model->hasToken('afterDelete')); + $this->model()->delete(1); + $this->assertTrue($this->model()->hasToken('beforeDelete')); + $this->assertTrue($this->model()->hasToken('afterDelete')); } public function testFindEvent(): void { - $this->model->find(1); - $this->assertTrue($this->model->hasToken('beforeFind')); - $this->assertTrue($this->model->hasToken('afterFind')); + $this->model()->find(1); + $this->assertTrue($this->model()->hasToken('beforeFind')); + $this->assertTrue($this->model()->hasToken('afterFind')); } public function testBeforeFindReturnsData(): void { - $this->model->beforeFindReturnData = true; + $this->model()->beforeFindReturnData = true; - $result = $this->model->find(1); - $this->assertTrue($this->model->hasToken('beforeFind')); + $result = $this->model()->find(1); + $this->assertTrue($this->model()->hasToken('beforeFind')); $this->assertSame('foobar', $result); } public function testBeforeFindReturnDataPreventsAfterFind(): void { - $this->model->beforeFindReturnData = true; - $this->model->find(1); - $this->assertFalse($this->model->hasToken('afterFind')); + $this->model()->beforeFindReturnData = true; + $this->model()->find(1); + $this->assertFalse($this->model()->hasToken('afterFind')); } public function testFindEventSingletons(): void { // afterFind - $this->model->first(); - $this->assertTrue($this->model->eventData['singleton']); + $this->model()->first(); + $this->assertTrue($this->model()->eventData['singleton']); - $this->model->find(1); - $this->assertTrue($this->model->eventData['singleton']); + $this->model()->find(1); + $this->assertTrue($this->model()->eventData['singleton']); - $this->model->find(); - $this->assertFalse($this->model->eventData['singleton']); + $this->model()->find(); + $this->assertFalse($this->model()->eventData['singleton']); - $this->model->findAll(); - $this->assertFalse($this->model->eventData['singleton']); + $this->model()->findAll(); + $this->assertFalse($this->model()->eventData['singleton']); // beforeFind - $this->model->beforeFindReturnData = true; + $this->model()->beforeFindReturnData = true; - $this->model->first(); - $this->assertTrue($this->model->eventData['singleton']); + $this->model()->first(); + $this->assertTrue($this->model()->eventData['singleton']); - $this->model->find(1); - $this->assertTrue($this->model->eventData['singleton']); + $this->model()->find(1); + $this->assertTrue($this->model()->eventData['singleton']); - $this->model->find(); - $this->assertFalse($this->model->eventData['singleton']); + $this->model()->find(); + $this->assertFalse($this->model()->eventData['singleton']); - $this->model->findAll(); - $this->assertFalse($this->model->eventData['singleton']); + $this->model()->findAll(); + $this->assertFalse($this->model()->eventData['singleton']); } public function testAllowCallbacksFalsePreventsTriggers(): void { - $this->model->allowCallbacks(false)->find(1); - $this->assertFalse($this->model->hasToken('afterFind')); + $this->model()->allowCallbacks(false)->find(1); + $this->assertFalse($this->model()->hasToken('afterFind')); } public function testAllowCallbacksTrueFiresTriggers(): void { - $this->setPrivateProperty($this->model, 'allowCallbacks', false); - $this->model->allowCallbacks(true)->find(1); - $this->assertTrue($this->model->hasToken('afterFind')); + $this->setPrivateProperty($this->model(), 'allowCallbacks', false); + $this->model()->allowCallbacks(true)->find(1); + $this->assertTrue($this->model()->hasToken('afterFind')); } public function testAllowCallbacksResetsAfterTrigger(): void { - $this->model->allowCallbacks(false)->find(1); - $this->model->delete(1); + $this->model()->allowCallbacks(false)->find(1); + $this->model()->delete(1); - $this->assertFalse($this->model->hasToken('afterFind')); - $this->assertTrue($this->model->hasToken('afterDelete')); + $this->assertFalse($this->model()->hasToken('afterFind')); + $this->assertTrue($this->model()->hasToken('afterDelete')); } public function testAllowCallbacksUsesModelProperty(): void { - $this->setPrivateProperty($this->model, 'allowCallbacks', false); - $this->setPrivateProperty($this->model, 'tempAllowCallbacks', false); // Was already set by the constructor + $this->setPrivateProperty($this->model(), 'allowCallbacks', false); + $this->setPrivateProperty($this->model(), 'tempAllowCallbacks', false); // Was already set by the constructor - $this->model->find(1); - $this->model->delete(1); + $this->model()->find(1); + $this->model()->delete(1); - $this->assertFalse($this->model->hasToken('afterFind')); - $this->assertFalse($this->model->hasToken('afterDelete')); + $this->assertFalse($this->model()->hasToken('afterFind')); + $this->assertFalse($this->model()->hasToken('afterDelete')); } public function testInvalidEventException(): void @@ -168,11 +170,11 @@ public function testInvalidEventException(): void 'deleted' => 0, ]; - $this->setPrivateProperty($this->model, 'beforeInsert', ['anotherBeforeInsertMethod']); + $this->setPrivateProperty($this->model(), 'beforeInsert', ['anotherBeforeInsertMethod']); $this->expectException(DataException::class); $this->expectExceptionMessage('"anotherBeforeInsertMethod" is not a valid Model Event callback.'); - $this->model->insert($data); + $this->model()->insert($data); } public function testInsertBatchEvent(): void @@ -192,9 +194,9 @@ public function testInsertBatchEvent(): void ], ]; - $this->model->insertBatch($data); - $this->assertTrue($this->model->hasToken('beforeInsertBatch')); - $this->assertTrue($this->model->hasToken('afterInsertBatch')); + $this->model()->insertBatch($data); + $this->assertTrue($this->model()->hasToken('beforeInsertBatch')); + $this->assertTrue($this->model()->hasToken('afterInsertBatch')); } public function testUpdateBatchEvent(): void @@ -210,8 +212,8 @@ public function testUpdateBatchEvent(): void ], ]; - $this->model->updateBatch($data, 'name'); - $this->assertTrue($this->model->hasToken('beforeUpdateBatch')); - $this->assertTrue($this->model->hasToken('afterUpdateBatch')); + $this->model()->updateBatch($data, 'name'); + $this->assertTrue($this->model()->hasToken('beforeUpdateBatch')); + $this->assertTrue($this->model()->hasToken('afterUpdateBatch')); } } diff --git a/tests/system/Models/FindModelTest.php b/tests/system/Models/FindModelTest.php index b5efc8a64f64..0f8481b78eac 100644 --- a/tests/system/Models/FindModelTest.php +++ b/tests/system/Models/FindModelTest.php @@ -133,7 +133,7 @@ public function testFindClearsBinds(): void $binds = $this->model->builder()->getBinds(); $this->assertCount(0, $binds); - $query = $this->model->getLastQuery(); + $query = $this->model->db->getLastQuery(); $this->assertCount(1, $this->getPrivateProperty($query, 'binds')); } @@ -207,6 +207,9 @@ public function testFirstAggregate($groupBy, $total): void $this->assertSame($total, (int) $user->total); } + /** + * @return iterable + */ public static function provideFirstAggregate(): iterable { return [ @@ -293,6 +296,9 @@ public function testFirstRecoverTempUseSoftDeletes($aggregate, $groupBy): void $this->assertSame(2, (int) $user2->id); } + /** + * @return iterable + */ public static function provideAggregateAndGroupBy(): iterable { return [ diff --git a/tests/system/Models/GeneralModelTest.php b/tests/system/Models/GeneralModelTest.php index c0d3d806dbdd..eb72f6aec5eb 100644 --- a/tests/system/Models/GeneralModelTest.php +++ b/tests/system/Models/GeneralModelTest.php @@ -15,8 +15,11 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\Exceptions\BadMethodCallException; +use CodeIgniter\I18n\Time; use CodeIgniter\Model; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Models\JobModel; use Tests\Support\Models\UserModel; @@ -35,15 +38,11 @@ final class GeneralModelTest extends CIUnitTestCase */ private ?object $model = null; - protected function setUp(): void - { - parent::setUp(); - } - protected function tearDown(): void { parent::tearDown(); $this->resetServices(); + Time::setTestNow(); } /** @@ -88,7 +87,8 @@ public function testUndefinedModelMethod(): void { $this->expectException(BadMethodCallException::class); $this->expectExceptionMessage('Call to undefined method Tests\Support\Models\UserModel::undefinedMethodCall'); - $this->createModel(UserModel::class)->undefinedMethodCall(); + + $this->createModel(UserModel::class)->undefinedMethodCall(); // @phpstan-ignore method.notFound (Testing Model::__call() fallback) } public function testSetAllowedFields(): void @@ -115,6 +115,28 @@ public function testSetAllowedFields(): void $this->assertSame($allowed2, $this->getPrivateProperty($model, 'allowedFields')); } + #[DataProvider('provideCurrentTimestampPreservesSubseconds')] + public function testCurrentTimestampPreservesSubseconds(string $format, string $expected): void + { + Time::setTestNow('2024-07-09 09:13:34.654321'); + + $db = new MockConnection(['dateFormat' => ['datetime' => $format]]); + $model = $this->createModel(UserModel::class, $db); + $date = self::getPrivateMethodInvoker($model, 'setDate'); + + $this->assertSame($expected, $date()); + } + + /** + * @return iterable + */ + public static function provideCurrentTimestampPreservesSubseconds(): iterable + { + yield 'milliseconds' => ['Y-m-d H:i:s.v', '2024-07-09 09:13:34.654']; + + yield 'microseconds' => ['Y-m-d H:i:s.u', '2024-07-09 09:13:34.654321']; + } + public function testBuilderUsesModelTable(): void { $builder = $this->createModel(UserModel::class)->builder(); diff --git a/tests/system/Models/LiveModelTestCase.php b/tests/system/Models/LiveModelTestCase.php index 342daecdf302..31b2af8edbca 100644 --- a/tests/system/Models/LiveModelTestCase.php +++ b/tests/system/Models/LiveModelTestCase.php @@ -38,11 +38,6 @@ abstract class LiveModelTestCase extends CIUnitTestCase protected $seed = CITestSeeder::class; - protected function setUp(): void - { - parent::setUp(); - } - protected function tearDown(): void { parent::tearDown(); diff --git a/tests/system/Models/TimestampModelTest.php b/tests/system/Models/TimestampModelTest.php index 52989cff754a..f87f54772831 100644 --- a/tests/system/Models/TimestampModelTest.php +++ b/tests/system/Models/TimestampModelTest.php @@ -39,6 +39,8 @@ protected function tearDown(): void } /** + * @param array $data + * * @return int|string Insert ID */ private function allowDatesPrepareOneRecord(array $data): int|string @@ -59,6 +61,8 @@ private function allowDatesPrepareOneRecord(array $data): int|string } /** + * @param array $data + * * @return int|string Insert ID */ private function doNotAllowDatesPrepareOneRecord(array $data): int|string diff --git a/tests/system/Pager/PagerRendererTest.php b/tests/system/Pager/PagerRendererTest.php index d06a040a7925..6744c2522ab9 100644 --- a/tests/system/Pager/PagerRendererTest.php +++ b/tests/system/Pager/PagerRendererTest.php @@ -622,7 +622,7 @@ public function testGetNextPageNumberNull(): void } /** - * @param array> $details + * @param array{uri: URI, pageCount: int, total: int, currentPage: int, perPage: int} $details */ #[DataProvider('providePageStartEnd')] public function testPageStartEnd(array $details, int $pageStart, int $pageEnd): void @@ -635,7 +635,11 @@ public function testPageStartEnd(array $details, int $pageStart, int $pageEnd): } /** - * @return array> $details + * @return iterable */ public static function providePageStartEnd(): iterable { diff --git a/tests/system/Pager/PagerTest.php b/tests/system/Pager/PagerTest.php index b1ab5d7dc4cd..875f5c25b320 100644 --- a/tests/system/Pager/PagerTest.php +++ b/tests/system/Pager/PagerTest.php @@ -76,7 +76,9 @@ public function testSetPathRemembersPath(): void $details = $this->pager->getDetails(); - $this->assertSame('foo/bar', $details['uri']->getRoutePath()); + $uri = $details['uri']; + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('foo/bar', $uri->getRoutePath()); } public function testGetDetailsRecognizesPageQueryVar(): void diff --git a/tests/system/Publisher/PublisherRestrictionsTest.php b/tests/system/Publisher/PublisherRestrictionsTest.php index eaa326ee82eb..93fcb499faa1 100644 --- a/tests/system/Publisher/PublisherRestrictionsTest.php +++ b/tests/system/Publisher/PublisherRestrictionsTest.php @@ -70,6 +70,9 @@ public function testDefaultPublicRestrictions(string $path): void $this->assertSame($expected, $errors[$file]->getMessage()); } + /** + * @return iterable + */ public static function provideDefaultPublicRestrictions(): iterable { yield from [ @@ -98,6 +101,9 @@ public function testDestinations(string $destination, bool $allowed): void $this->assertInstanceOf(Publisher::class, $publisher); } + /** + * @return iterable + */ public static function provideDestinations(): iterable { return [ diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index 7e6e21e430d4..c63973e63d36 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -106,7 +106,6 @@ public function testGetScratch(): void $scratch = $publisher->getScratch(); - $this->assertIsString($scratch); $this->assertDirectoryExists($scratch); $this->assertDirectoryIsWritable($scratch); $this->assertNotNull($this->getPrivateProperty($publisher, 'scratch')); @@ -159,7 +158,8 @@ public function testWipe(): void { $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)); mkdir($directory, 0700); - $directory = realpath($directory) ?: $directory; + $realPath = realpath($directory); + $directory = $realPath === false ? $directory : $realPath; $this->assertDirectoryExists($directory); config('Publisher')->restrictions[$directory] = ''; // Allow the directory diff --git a/tests/system/RESTful/ResourceControllerTest.php b/tests/system/RESTful/ResourceControllerTest.php index c51e4ae39174..d660cb7c3f9c 100644 --- a/tests/system/RESTful/ResourceControllerTest.php +++ b/tests/system/RESTful/ResourceControllerTest.php @@ -51,11 +51,6 @@ final class ResourceControllerTest extends CIUnitTestCase { private CodeIgniter $codeigniter; - /** - * @var RouteCollection - */ - protected $routes; - #[WithoutErrorHandler] protected function setUp(): void { @@ -67,19 +62,27 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); } + private function routes(): RouteCollection + { + $this->assertInstanceOf(RouteCollection::class, $this->routes); + + return $this->routes; + } + private function createCodeigniter(): void { service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); // Inject mock router. $this->routes = service('routes'); - $this->routes->resource('work', ['controller' => '\\' . Worker::class]); - Services::injectMock('routes', $this->routes); + $this->routes()->resource('work', ['controller' => '\\' . Worker::class]); + Services::injectMock('routes', $this->routes()); $config = new App(); $this->codeigniter = new MockCodeIgniter($config); $response = service('response'); + $this->assertInstanceOf(Response::class, $response); $response->pretend(); } @@ -106,7 +109,7 @@ public function testResourceGet(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -128,7 +131,7 @@ public function testResourceGetNew(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -151,7 +154,7 @@ public function testResourceGetEdit(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -173,7 +176,7 @@ public function testResourceGetOne(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -194,7 +197,7 @@ public function testResourcePost(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -216,7 +219,7 @@ public function testResourcePatch(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -238,7 +241,7 @@ public function testResourcePut(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -260,7 +263,7 @@ public function testResourceDelete(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $error = json_decode($output)->messages->error; @@ -307,7 +310,7 @@ public function testFormat(): void $resource = new MockResourceController(); $this->assertSame('json', $resource->getFormat()); - $resource->setFormat('Nonsense'); + $resource->setFormat('Nonsense'); // @phpstan-ignore argument.type (Testing that an invalid format is silently ignored) $this->assertSame('json', $resource->getFormat()); $resource->setFormat('xml'); @@ -370,6 +373,9 @@ public function testXMLFormatOutput(): void $this->assertSame($expected, $result); } + /** + * @param list $args + */ private function invoke(object $controller, string $method, array $args = []): object { $method = self::getPrivateMethodInvoker($controller, $method); diff --git a/tests/system/RESTful/ResourcePresenterTest.php b/tests/system/RESTful/ResourcePresenterTest.php index 7e002d8e48b7..8f691c57296b 100644 --- a/tests/system/RESTful/ResourcePresenterTest.php +++ b/tests/system/RESTful/ResourcePresenterTest.php @@ -45,11 +45,6 @@ final class ResourcePresenterTest extends CIUnitTestCase { private CodeIgniter $codeigniter; - /** - * @var RouteCollection - */ - protected $routes; - #[WithoutErrorHandler] protected function setUp(): void { @@ -61,14 +56,21 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); } + private function routes(): RouteCollection + { + $this->assertInstanceOf(RouteCollection::class, $this->routes); + + return $this->routes; + } + private function createCodeigniter(): void { service('superglobals')->setServer('SERVER_PROTOCOL', 'HTTP/1.1'); // Inject mock router. $this->routes = service('routes'); - $this->routes->presenter('work', ['controller' => '\\' . Worker2::class]); - Services::injectMock('routes', $this->routes); + $this->routes()->presenter('work', ['controller' => '\\' . Worker2::class]); + Services::injectMock('routes', $this->routes()); $config = new App(); $this->codeigniter = new MockCodeIgniter($config); @@ -97,7 +99,7 @@ public function testResourceGet(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertSame(lang('RESTful.notImplemented', ['index']), $output); @@ -119,7 +121,7 @@ public function testResourceShow(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertStringContainsString(lang('RESTful.notImplemented', ['show']), (string) $output); @@ -140,7 +142,7 @@ public function testResourceNew(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertStringContainsString(lang('RESTful.notImplemented', ['new']), (string) $output); @@ -161,7 +163,7 @@ public function testResourceCreate(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertStringContainsString(lang('RESTful.notImplemented', ['create']), (string) $output); @@ -183,7 +185,7 @@ public function testResourceRemove(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertStringContainsString(lang('RESTful.notImplemented', ['remove']), (string) $output); @@ -205,7 +207,7 @@ public function testResourceDelete(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertStringContainsString(lang('RESTful.notImplemented', ['delete']), (string) $output); @@ -228,7 +230,7 @@ public function testResourceEdit(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertStringContainsString(lang('RESTful.notImplemented', ['edit']), (string) $output); @@ -250,7 +252,7 @@ public function testResourceUpdate(): void $this->createCodeigniter(); ob_start(); - $this->codeigniter->run($this->routes); + $this->codeigniter->run($this->routes()); $output = ob_get_clean(); $this->assertStringContainsString(lang('RESTful.notImplemented', ['update']), (string) $output); diff --git a/tests/system/Router/Attributes/CacheTest.php b/tests/system/Router/Attributes/CacheTest.php index 1570c9658938..f31045336973 100644 --- a/tests/system/Router/Attributes/CacheTest.php +++ b/tests/system/Router/Attributes/CacheTest.php @@ -221,7 +221,7 @@ private function createMockRequest(string $method, string $path, string $query = ->getMock(); $request->method('isCLI')->willReturn(false); $request->method('withMethod')->willReturnCallback( - static function (string $method) use ($request): MockObject&IncomingRequest { + static function (string $method) use ($request): IncomingRequest&MockObject { $request->method('getMethod')->willReturn($method); return $request; diff --git a/tests/system/Router/AutoRouterImprovedTest.php b/tests/system/Router/AutoRouterImprovedTest.php index 8a4132a8f297..a974509e94c4 100644 --- a/tests/system/Router/AutoRouterImprovedTest.php +++ b/tests/system/Router/AutoRouterImprovedTest.php @@ -528,6 +528,9 @@ public function testTranslateUriToCamelCase( ], $router->getPos()); } + /** + * @return iterable + */ public static function provideTranslateUriToCamelCase(): iterable { yield from [ @@ -587,6 +590,9 @@ public function testRejectTranslateUriToCamelCase(string $uri, string $expMsg): $router->getRoute($uri, Method::GET); } + /** + * @return iterable + */ public static function provideRejectTranslateUriToCamelCase(): iterable { yield from [ diff --git a/tests/system/Router/Controllers/BlogController.php b/tests/system/Router/Controllers/BlogController.php index 2287d8ed4dec..cd22bc25357b 100644 --- a/tests/system/Router/Controllers/BlogController.php +++ b/tests/system/Router/Controllers/BlogController.php @@ -21,7 +21,7 @@ public function getIndex(): void { } - public function getSomeMethod($first = ''): void + public function getSomeMethod(string $first = ''): void { } } diff --git a/tests/system/Router/Controllers/Index.php b/tests/system/Router/Controllers/Index.php index 8b1c42ec9997..c17b9b086722 100644 --- a/tests/system/Router/Controllers/Index.php +++ b/tests/system/Router/Controllers/Index.php @@ -17,7 +17,7 @@ class Index extends Controller { - public function getIndex($p1 = ''): void + public function getIndex(string $p1 = ''): void { } diff --git a/tests/system/Router/Controllers/Mycontroller.php b/tests/system/Router/Controllers/Mycontroller.php index 75ad1eae026e..a568390188cf 100644 --- a/tests/system/Router/Controllers/Mycontroller.php +++ b/tests/system/Router/Controllers/Mycontroller.php @@ -21,7 +21,7 @@ public function getIndex(): void { } - public function getSomemethod($first = ''): void + public function getSomemethod(string $first = ''): void { } } diff --git a/tests/system/Router/Controllers/Remap.php b/tests/system/Router/Controllers/Remap.php index 35a7f4b577ac..ef570c423a65 100644 --- a/tests/system/Router/Controllers/Remap.php +++ b/tests/system/Router/Controllers/Remap.php @@ -18,7 +18,7 @@ class Remap extends Controller { - public function _remap(string $method, ...$params): string + public function _remap(string $method, mixed ...$params): string { $method = 'process_' . $method; diff --git a/tests/system/Router/Controllers/SubDir/BlogController.php b/tests/system/Router/Controllers/SubDir/BlogController.php index 78bfcdef0f1d..29a84a5dc467 100644 --- a/tests/system/Router/Controllers/SubDir/BlogController.php +++ b/tests/system/Router/Controllers/SubDir/BlogController.php @@ -21,7 +21,7 @@ public function getIndex(): void { } - public function getSomeMethod($first = ''): void + public function getSomeMethod(string $first = ''): void { } } diff --git a/tests/system/Router/Controllers/Subfolder/Home.php b/tests/system/Router/Controllers/Subfolder/Home.php index 386a86d81c66..5451aec202ea 100644 --- a/tests/system/Router/Controllers/Subfolder/Home.php +++ b/tests/system/Router/Controllers/Subfolder/Home.php @@ -17,7 +17,7 @@ class Home extends Controller { - public function getIndex($p1 = null, $p2 = null): void + public function getIndex(?string $p1 = null, ?string $p2 = null): void { } } diff --git a/tests/system/Router/Controllers/Subfolder/Sub/BlogController.php b/tests/system/Router/Controllers/Subfolder/Sub/BlogController.php index 9481c75050d8..3d2a733c25fe 100644 --- a/tests/system/Router/Controllers/Subfolder/Sub/BlogController.php +++ b/tests/system/Router/Controllers/Subfolder/Sub/BlogController.php @@ -21,7 +21,7 @@ public function getIndex(): void { } - public function getSomeMethod($first = ''): void + public function getSomeMethod(string $first = ''): void { } } diff --git a/tests/system/Router/DefinedRouteCollectorTest.php b/tests/system/Router/DefinedRouteCollectorTest.php index 2ec354c88893..31a535e130c6 100644 --- a/tests/system/Router/DefinedRouteCollectorTest.php +++ b/tests/system/Router/DefinedRouteCollectorTest.php @@ -25,7 +25,10 @@ #[Group('Others')] final class DefinedRouteCollectorTest extends CIUnitTestCase { - private function createRouteCollection(array $config = [], $moduleConfig = null): RouteCollection + /** + * @param array|non-empty-string> $config + */ + private function createRouteCollection(array $config = [], ?Modules $moduleConfig = null): RouteCollection { $defaults = [ 'Config' => APPPATH . 'Config', @@ -37,7 +40,7 @@ private function createRouteCollection(array $config = [], $moduleConfig = null) $loader = service('locator'); - if ($moduleConfig === null) { + if (! $moduleConfig instanceof Modules) { $moduleConfig = new Modules(); $moduleConfig->enabled = false; } diff --git a/tests/system/Router/RouteCollectionReverseRouteTest.php b/tests/system/Router/RouteCollectionReverseRouteTest.php index b468c021c87d..36ead775da06 100644 --- a/tests/system/Router/RouteCollectionReverseRouteTest.php +++ b/tests/system/Router/RouteCollectionReverseRouteTest.php @@ -35,7 +35,11 @@ protected function setUp(): void $this->resetFactories(); } - protected function getCollector(array $config = [], array $files = [], $moduleConfig = null): RouteCollection + /** + * @param array|non-empty-string> $config + * @param array $files + */ + protected function getCollector(array $config = [], array $files = [], ?Modules $moduleConfig = null): RouteCollection { $defaults = [ 'Config' => APPPATH . 'Config', @@ -47,7 +51,7 @@ protected function getCollector(array $config = [], array $files = [], $moduleCo $loader = service('locator'); - if ($moduleConfig === null) { + if (! $moduleConfig instanceof Modules) { $moduleConfig = new Modules(); $moduleConfig->enabled = false; } @@ -147,6 +151,9 @@ public function testReverseRoutingDefaultNamespaceAppController(string $controll $this->assertSame('/users/15/gallery12', $match); } + /** + * @return iterable + */ public static function provideReverseRoutingDefaultNamespaceAppController(): iterable { return yield from [ diff --git a/tests/system/Router/RouteCollectionTest.php b/tests/system/Router/RouteCollectionTest.php index 6346697d206a..9ceec705cf6b 100644 --- a/tests/system/Router/RouteCollectionTest.php +++ b/tests/system/Router/RouteCollectionTest.php @@ -44,7 +44,11 @@ protected function setUp(): void Services::injectMock('superglobals', new Superglobals()); } - protected function getCollector(array $config = [], array $files = [], $moduleConfig = null): RouteCollection + /** + * @param array|non-empty-string> $config + * @param array $files + */ + protected function getCollector(array $config = [], array $files = [], ?Modules $moduleConfig = null): RouteCollection { $defaults = [ 'Config' => APPPATH . 'Config', @@ -56,7 +60,7 @@ protected function getCollector(array $config = [], array $files = [], $moduleCo $loader = service('locator'); - if ($moduleConfig === null) { + if (! $moduleConfig instanceof Modules) { $moduleConfig = new Modules(); $moduleConfig->enabled = false; } @@ -196,13 +200,13 @@ public function testMatchIgnoresInvalidHTTPMethods(): void $this->assertSame([], $routes); } - public function testAddWorksWithArrayOFHTTPMethods(): void + public function testAddWorksWithArrayOfHTTPMethods(): void { service('request')->setMethod(Method::POST); $routes = $this->getCollector(); - $routes->add('home', 'controller', ['get', 'post']); + $routes->add('home', 'controller'); $expects = [ 'home' => '\controller', @@ -507,6 +511,9 @@ static function ($routes): void { $this->assertSame($expected, $routes->getRoutes()); } + /** + * @param array $expected + */ #[DataProvider('provideNestedGroupingWorksWithRootPrefix')] public function testNestedGroupingWorksWithRootPrefix( string $group, @@ -531,6 +538,9 @@ static function ($routes): void { $this->assertSame($expected, $routes->getRoutes()); } + /** + * @return iterable}> + */ public static function provideNestedGroupingWorksWithRootPrefix(): iterable { yield from [ @@ -1340,9 +1350,13 @@ static function (): void {}, $options = $routes->getRoutesOptions('administrator'); - $this->assertSame($options, ['as' => 'admin', 'foo' => 'baz']); + $this->assertSame(['as' => 'admin', 'foo' => 'baz'], $options); } + /** + * @param array $options1 + * @param array $options2 + */ #[DataProvider('provideRoutesOptionsWithSameFromTwoRoutes')] public function testRoutesOptionsWithSameFromTwoRoutes(array $options1, array $options2): void { @@ -1366,6 +1380,9 @@ static function (): void {}, $this->assertSame($options, $options1); } + /** + * @return iterable, array}> + */ public static function provideRoutesOptionsWithSameFromTwoRoutes(): iterable { yield from [ @@ -1442,15 +1459,15 @@ static function (): void {}, $options = $routes->getRoutesOptions('administrator'); - $this->assertSame($options, ['as' => 'admin1', 'foo' => 'baz1', 'bar' => 'baz']); + $this->assertSame(['as' => 'admin1', 'foo' => 'baz1', 'bar' => 'baz'], $options); $options = $routes->setHTTPVerb(Method::POST)->getRoutesOptions('administrator'); - $this->assertSame($options, ['as' => 'admin2', 'foo' => 'baz2', 'bar' => 'baz']); + $this->assertSame(['as' => 'admin2', 'foo' => 'baz2', 'bar' => 'baz'], $options); $options = $routes->setHTTPVerb(Method::GET)->getRoutesOptions('administrator', Method::POST); - $this->assertSame($options, ['as' => 'admin2', 'foo' => 'baz2', 'bar' => 'baz']); + $this->assertSame(['as' => 'admin2', 'foo' => 'baz2', 'bar' => 'baz'], $options); } public function testRouteGroupWithFilterSimple(): void @@ -1860,6 +1877,9 @@ public function testRoutesControllerNameReturnsFQCN(string $namespace): void $this->assertSame('\\' . Product::class, $router->controllerName()); } + /** + * @return iterable + */ public static function provideRouteDefaultNamespace(): iterable { return [ diff --git a/tests/system/Router/RouterTest.php b/tests/system/Router/RouterTest.php index 7eef0a7569aa..6e22c95ed4f9 100644 --- a/tests/system/Router/RouterTest.php +++ b/tests/system/Router/RouterTest.php @@ -550,7 +550,7 @@ public function testMatchedRouteOptions(): void $router->handle('foo'); - $this->assertSame($router->getMatchedRouteOptions(), ['as' => 'login', 'foo' => 'baz']); + $this->assertSame(['as' => 'login', 'foo' => 'baz'], $router->getMatchedRouteOptions()); } public function testRouteWorksWithFilters(): void @@ -963,6 +963,9 @@ public function testRedirectRoute( $router->handle($url); } + /** + * @return iterable + */ public static function provideRedirectRoute(): iterable { // [$route, $redirectFrom, $redirectTo, $url, $expectedPath, $alias] diff --git a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php index f5f1f437c83d..1c242e249519 100644 --- a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php @@ -76,7 +76,10 @@ protected function setUp(): void $this->injectSession($this->hash); } - private function createSession($options = []): Session + /** + * @param array $options + */ + private function createSession(array $options = []): Session { $defaults = [ 'driver' => FileHandler::class, diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php index fb410ac71ac3..fbb28e5baaf1 100644 --- a/tests/system/Security/SecurityCSRFSessionTest.php +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -69,7 +69,10 @@ protected function setUp(): void $this->injectSession($this->hash); } - private function createSession($options = []): Session + /** + * @param array $options + */ + private function createSession(array $options = []): Session { $defaults = [ 'driver' => FileHandler::class, diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index 932dfc0df2c0..db80d91cbd92 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -350,11 +350,11 @@ public function testGetters(): void { $security = $this->createMockSecurity(); - $this->assertIsString($security->getHash()); - $this->assertIsString($security->getTokenName()); - $this->assertIsString($security->getHeaderName()); - $this->assertIsString($security->getCookieName()); - $this->assertIsBool($security->shouldRedirect()); + $this->assertSame(32, strlen($security->getHash())); + $this->assertSame('csrf_test_name', $security->getTokenName()); + $this->assertSame('X-CSRF-TOKEN', $security->getHeaderName()); + $this->assertSame('csrf_cookie_name', $security->getCookieName()); + $this->assertFalse($security->shouldRedirect()); } public function testGetPostedTokenReturnsTokenFromPost(): void diff --git a/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php b/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php index b5760d7f4f4e..743508d7fe55 100644 --- a/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php +++ b/tests/system/Session/Handlers/Database/AbstractHandlerTestCase.php @@ -37,11 +37,11 @@ abstract class AbstractHandlerTestCase extends CIUnitTestCase protected function setUp(): void { - parent::setUp(); - if (! in_array(config(DatabaseConfig::class)->tests['DBDriver'], ['MySQLi', 'Postgre'], true)) { $this->markTestSkipped('Database Session Handler requires database driver to be MySQLi or Postgre'); } + + parent::setUp(); } /** diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 637132669aec..c2f549e31659 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -142,7 +142,8 @@ public function testSecondaryReadAfterClose(): void $expected = <<<'DATA' __ci_last_regenerate|i:1664607454;_ci_previous_url|s:32:"http://localhost:8080/index.php/";key|s:5:"value"; DATA; - $this->assertSame($expected, $handler->read('555556b43phsnnf8if6bo33b635e4447')); + $data = $handler->read('555556b43phsnnf8if6bo33b635e4447'); + $this->assertSame($expected, $data); $handler->close(); diff --git a/tests/system/Session/Handlers/FileHandlerTest.php b/tests/system/Session/Handlers/FileHandlerTest.php new file mode 100644 index 000000000000..3c94aacad693 --- /dev/null +++ b/tests/system/Session/Handlers/FileHandlerTest.php @@ -0,0 +1,487 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Session\Handlers; + +use Closure; +use CodeIgniter\Session\Exceptions\SessionException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\TestLogger; +use Config\Logger as LoggerConfig; +use Config\Session as SessionConfig; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; + +/** + * @internal + */ +#[Group('Others')] +final class FileHandlerTest extends CIUnitTestCase +{ + private string $sessionDriver = FileHandler::class; + private string $sessionName = 'ci_session'; + private string $userIpAddress = '127.0.0.1'; + private string $tempPath; + private string $originalSavePath; + private string $originalSidBits; + private string $originalSidLength; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalSavePath = (string) ini_get('session.save_path'); + $this->originalSidBits = (string) ini_get('session.sid_bits_per_character'); + $this->originalSidLength = (string) ini_get('session.sid_length'); + + $this->tempPath = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'FileHandlerTest_' . bin2hex(random_bytes(6)); + mkdir($this->tempPath, 0700, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + + ini_set('session.save_path', $this->originalSavePath); + ini_set('session.sid_bits_per_character', $this->originalSidBits); + ini_set('session.sid_length', $this->originalSidLength); + + $this->removeDirectory($this->tempPath); + } + + /** + * @param array $options Replace values for `Config\Session`. + */ + protected function getInstance(array $options = []): FileHandler + { + $defaults = [ + 'driver' => $this->sessionDriver, + 'cookieName' => $this->sessionName, + 'expiration' => 7200, + 'savePath' => $this->tempPath, + 'matchIP' => false, + 'timeToUpdate' => 300, + 'regenerateDestroy' => false, + ]; + $sessionConfig = new SessionConfig(); + $config = array_merge($defaults, $options); + + foreach ($config as $key => $value) { + $sessionConfig->{$key} = $value; + } + + $handler = new FileHandler($sessionConfig, $this->userIpAddress); + $handler->setLogger(new TestLogger(new LoggerConfig())); + + return $handler; + } + + /** + * Runs a callback while suppressing any PHP warnings it raises. + * + * @param Closure(): mixed $callback + */ + private function withSuppressedErrors(Closure $callback): mixed + { + set_error_handler(static fn (int $severity, string $message): bool => true); + + try { + return $callback(); + } finally { + restore_error_handler(); + } + } + + private function removeDirectory(string $path): void + { + if (! is_dir($path)) { + return; + } + + $items = scandir($path); + + foreach ($items === false ? [] : $items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $target = $path . DIRECTORY_SEPARATOR . $item; + + if (is_dir($target)) { + $this->removeDirectory($target); + } else { + @unlink($target); + } + } + + @chmod($path, 0700); + @rmdir($path); + } + + private static function sessionId(string $char): string + { + return str_repeat($char, 32); + } + + // -------------------------------------------------------------------- + // Constructor & Initialization + // -------------------------------------------------------------------- + + public function testConstructorTrimsAndSetsIniSavePath(): void + { + $this->getInstance(['savePath' => $this->tempPath . '//']); + + $this->assertSame($this->tempPath, ini_get('session.save_path')); + } + + public function testConstructorForcesSessionIDRegexSettings(): void + { + ini_set('session.sid_bits_per_character', '5'); + ini_set('session.sid_length', '26'); + + $this->getInstance(); + + $this->assertSame('4', ini_get('session.sid_bits_per_character')); + $this->assertSame('32', ini_get('session.sid_length')); + } + + // -------------------------------------------------------------------- + // open() + // -------------------------------------------------------------------- + + public function testOpenExistingDirectory(): void + { + $handler = $this->getInstance(); + + $this->assertTrue($handler->open($this->tempPath, $this->sessionName)); + } + + public function testOpenWithMatchIPIsolatesSessions(): void + { + $id = self::sessionId('1'); + $data = 'ip_test_data'; + + $handler1 = $this->getInstance(['matchIP' => true]); + $this->assertTrue($handler1->open($this->tempPath, $this->sessionName)); + $this->assertSame('', $handler1->read($id)); + $this->assertTrue($handler1->write($id, $data)); + $this->assertTrue($handler1->close()); + + // File created with hashed IP in filename + $this->assertFileExists($this->tempPath . '/' . $this->sessionName . md5($this->userIpAddress) . $id); + + // Handler with different IP cannot read the session (isolation) + $sessionConfig = new SessionConfig(); + $sessionConfig->driver = $this->sessionDriver; + $sessionConfig->cookieName = $this->sessionName; + $sessionConfig->savePath = $this->tempPath; + $sessionConfig->matchIP = true; + + $handler2 = new FileHandler($sessionConfig, '192.168.1.1'); + $handler2->setLogger(new TestLogger(new LoggerConfig())); + $this->assertTrue($handler2->open($this->tempPath, $this->sessionName)); + $this->assertSame('', $handler2->read($id)); + $this->assertTrue($handler2->close()); + } + + public function testOpenCreatesMissingDirectory(): void + { + $handler = $this->getInstance(); + + $path = $this->tempPath . '/new/nested'; + + $this->assertTrue($handler->open($path, $this->sessionName)); + $this->assertDirectoryExists($path); + } + + public function testOpenThrowsForInvalidSavePath(): void + { + file_put_contents($this->tempPath . '/blocker', 'data'); + + $handler = $this->getInstance(); + + $this->expectException(SessionException::class); + $this->withSuppressedErrors(fn (): bool => $handler->open($this->tempPath . '/blocker/sub', $this->sessionName)); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testOpenThrowsForWriteProtectedSavePath(): void + { + $dir = $this->tempPath . '/readonly'; + mkdir($dir, 0700); + chmod($dir, 0555); + + if (is_writable($dir)) { + $this->markTestSkipped('Directory permissions cannot be restricted for this user (e.g. running as root).'); + } + + try { + $handler = $this->getInstance(); + + $this->expectException(SessionException::class); + $handler->open($dir, $this->sessionName); + } finally { + chmod($dir, 0755); + } + } + + // -------------------------------------------------------------------- + // read() + // -------------------------------------------------------------------- + + public function testReadNewSession(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('1'); + + $this->assertSame('', $handler->read($id)); + + $handler->close(); + } + + public function testReadExistingSession(): void + { + $id = self::sessionId('2'); + $data = '__ci_last_regenerate|i:1664607454;key|s:5:"value";'; + file_put_contents($this->tempPath . '/' . $this->sessionName . $id, $data); + + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $this->assertSame($data, $handler->read($id)); + + $handler->close(); + } + + public function testReadTwiceWithoutCloseRewinds(): void + { + $id = self::sessionId('3'); + $data = '__ci_last_regenerate|i:1664607454;key|s:5:"value";'; + + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $this->assertSame('', $handler->read($id)); + $this->assertTrue($handler->write($id, $data)); + + $this->assertSame($data, $handler->read($id)); + + $handler->close(); + } + + public function testReadFailsWhenFileCannotBeOpened(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('4'); + mkdir($this->tempPath . '/' . $this->sessionName . $id, 0700, true); + + $result = $this->withSuppressedErrors(static fn (): false|string => $handler->read($id)); + + $this->assertFalse($result); + $this->assertLogContains('error', 'Session: Unable to open file'); + } + + // -------------------------------------------------------------------- + // write() + // -------------------------------------------------------------------- + + public function testWriteFailsWhenNoFileHandle(): void + { + $handler = $this->getInstance(); + + $this->assertFalse($handler->write(self::sessionId('a'), 'data')); + } + + public function testReadWriteRoundtrip(): void + { + $id = self::sessionId('b'); + $data = '__ci_last_regenerate|i:1664607454;key|s:5:"value";'; + + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $this->assertSame('', $handler->read($id)); + $this->assertTrue($handler->write($id, $data)); + // Identical data on a new file returns true without writing again. + $this->assertTrue($handler->write($id, $data)); + + $handler->close(); + + $this->assertSame($data, file_get_contents($this->tempPath . '/' . $this->sessionName . $id)); + + // A second handler reads the file back; an identical write just touches it. + $handler2 = $this->getInstance(); + $handler2->open($this->tempPath, $this->sessionName); + + $this->assertSame($data, $handler2->read($id)); + $this->assertTrue($handler2->write($id, $data)); + + // Different data truncates and rewrites the existing file. + $data2 = 'changed|i:1664607455;key|s:6:"value2";'; + $this->assertTrue($handler2->write($id, $data2)); + + $handler2->close(); + + $this->assertSame($data2, file_get_contents($this->tempPath . '/' . $this->sessionName . $id)); + } + + public function testWriteEmptyDataClearsSession(): void + { + $id = self::sessionId('c'); + $data = 'some_data'; + + $handler1 = $this->getInstance(); + $handler1->open($this->tempPath, $this->sessionName); + $this->assertSame('', $handler1->read($id)); + $this->assertTrue($handler1->write($id, $data)); + $handler1->close(); + + $this->assertSame($data, file_get_contents($this->tempPath . '/' . $this->sessionName . $id)); + + $handler2 = $this->getInstance(); + $handler2->open($this->tempPath, $this->sessionName); + $this->assertSame($data, $handler2->read($id)); + $this->assertTrue($handler2->write($id, '')); + $handler2->close(); + + $this->assertSame('', file_get_contents($this->tempPath . '/' . $this->sessionName . $id)); + } + + public function testWriteWithDifferentSessionID(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id1 = self::sessionId('d'); + $id2 = self::sessionId('e'); + + $this->assertSame('', $handler->read($id1)); + $this->assertTrue($handler->write($id2, 'data')); + $handler->close(); + } + + // -------------------------------------------------------------------- + // close() + // -------------------------------------------------------------------- + + public function testCloseWithOpenFile(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + $handler->read(self::sessionId('1')); + + $this->assertTrue($handler->close()); + // After closing, write fails + $this->assertFalse($handler->write(self::sessionId('1'), 'data')); + } + + public function testCloseWithoutOpenFile(): void + { + $handler = $this->getInstance(); + + $this->assertTrue($handler->close()); + } + + // -------------------------------------------------------------------- + // destroy() + // -------------------------------------------------------------------- + + public function testDestroyExistingSession(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('2'); + $handler->read($id); + $handler->close(); + + $this->assertFileExists($this->tempPath . '/' . $this->sessionName . $id); + $this->assertTrue($handler->destroy($id)); + $this->assertFileDoesNotExist($this->tempPath . '/' . $this->sessionName . $id); + } + + public function testDestroyNonexistentSession(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + $handler->close(); + + $this->assertTrue($handler->destroy(self::sessionId('3'))); + } + + // -------------------------------------------------------------------- + // gc() + // -------------------------------------------------------------------- + + public function testGCWhenSavePathMissing(): void + { + $handler = $this->getInstance(['savePath' => $this->tempPath . '/missing']); + + $this->assertFalse($handler->gc(3600)); + $this->assertLogContains('debug', "Session: Garbage collector couldn't list files under directory"); + } + + public function testGCRemovesExpiredSessionsOnly(): void + { + $handler = $this->getInstance(['savePath' => $this->tempPath]); + + $expired = $this->tempPath . '/' . $this->sessionName . self::sessionId('a'); + file_put_contents($expired, 'data'); + touch($expired, time() - 4000); + + $recent = $this->tempPath . '/' . $this->sessionName . self::sessionId('b'); + file_put_contents($recent, 'data'); + touch($recent); + + $directory = $this->tempPath . '/' . $this->sessionName . self::sessionId('c'); + mkdir($directory, 0700); + + $foreign = $this->tempPath . '/not-a-session-file'; + file_put_contents($foreign, 'data'); + touch($foreign, time() - 4000); + + $nonHex = $this->tempPath . '/' . $this->sessionName . str_repeat('z', 32); + file_put_contents($nonHex, 'data'); + touch($nonHex, time() - 4000); + + $this->assertSame(1, $handler->gc(3600)); + + $this->assertFileDoesNotExist($expired); + $this->assertFileExists($recent); + $this->assertDirectoryExists($directory); + $this->assertFileExists($foreign); + $this->assertFileExists($nonHex); + } + + public function testGCWithMatchIP(): void + { + $handler = $this->getInstance(['savePath' => $this->tempPath, 'matchIP' => true]); + + $expired = $this->tempPath . '/' . $this->sessionName . md5($this->userIpAddress) . self::sessionId('a'); + file_put_contents($expired, 'data'); + touch($expired, time() - 4000); + + $withoutIp = $this->tempPath . '/' . $this->sessionName . self::sessionId('b'); + file_put_contents($withoutIp, 'data'); + touch($withoutIp, time() - 4000); + + $this->assertSame(1, $handler->gc(3600)); + + $this->assertFileDoesNotExist($expired); + $this->assertFileExists($withoutIp); + } +} diff --git a/tests/system/Session/SessionTest.php b/tests/system/Session/SessionTest.php index 3b7cd3860ccd..91ed72173097 100644 --- a/tests/system/Session/SessionTest.php +++ b/tests/system/Session/SessionTest.php @@ -80,7 +80,8 @@ public function testSessionSetsRegenerateTime(): void $session = $this->getInstance(); $session->start(); - $this->assertTrue(isset($_SESSION['__ci_last_regenerate']) && ! empty($_SESSION['__ci_last_regenerate'])); + $this->assertArrayHasKey('__ci_last_regenerate', $_SESSION); + $this->assertGreaterThan(0, $_SESSION['__ci_last_regenerate']); } public function testWillRegenerateSessionAutomatically(): void @@ -666,7 +667,7 @@ public function testInvalidSameSite(): void $this->expectExceptionMessage(lang('Cookie.invalidSameSite', ['Invalid'])); $config = new CookieConfig(); - $config->samesite = 'Invalid'; + $config->samesite = 'Invalid'; // @phpstan-ignore assign.propertyType (Testing rejection of an invalid SameSite value) Factories::injectMock('config', CookieConfig::class, $config); diff --git a/tests/system/SuperglobalsTest.php b/tests/system/SuperglobalsTest.php index de1fed832f3a..e4b8b23b2b28 100644 --- a/tests/system/SuperglobalsTest.php +++ b/tests/system/SuperglobalsTest.php @@ -57,16 +57,16 @@ public function testServerSetWithArray(): void public function testServerSetWithInt(): void { - $this->superglobals->setServer('REQUEST_TIME', 1234567890); + $this->superglobals->setServer('REQUEST_TIME', 1_234_567_890); - $this->assertSame(1234567890, $this->superglobals->server('REQUEST_TIME')); + $this->assertSame(1_234_567_890, $this->superglobals->server('REQUEST_TIME')); } public function testServerSetWithFloat(): void { - $this->superglobals->setServer('REQUEST_TIME_FLOAT', 1234567890.123); + $this->superglobals->setServer('REQUEST_TIME_FLOAT', 1_234_567_890.123); - $this->assertEqualsWithDelta(1234567890.123, $this->superglobals->server('REQUEST_TIME_FLOAT'), PHP_FLOAT_EPSILON); + $this->assertEqualsWithDelta(1_234_567_890.123, $this->superglobals->server('REQUEST_TIME_FLOAT'), PHP_FLOAT_EPSILON); } public function testServerUnset(): void diff --git a/tests/system/Test/ControllerTestTraitTest.php b/tests/system/Test/ControllerTestTraitTest.php index 44301afa376d..5662c773d4cd 100644 --- a/tests/system/Test/ControllerTestTraitTest.php +++ b/tests/system/Test/ControllerTestTraitTest.php @@ -222,7 +222,7 @@ public function testFailsForward(): void ->execute('index'); // won't fail, but doesn't do anything - $this->assertNull($result->ohno('Hi')); + $this->assertNull($result->ohno('Hi')); // @phpstan-ignore method.notFound (Testing TestResponse::__call() fallback) } /** diff --git a/tests/system/Test/DOMParserTest.php b/tests/system/Test/DOMParserTest.php index ce43f2863cc6..58d4e332ce40 100644 --- a/tests/system/Test/DOMParserTest.php +++ b/tests/system/Test/DOMParserTest.php @@ -144,6 +144,9 @@ public function testSeeElement($text): void $this->assertTrue($dom->see($text, 'h1')); } + /** + * @return iterable + */ public static function provideText(): iterable { return [ diff --git a/tests/system/Test/FabricatorTest.php b/tests/system/Test/FabricatorTest.php index 46ab9441dd74..7eaa81863cfd 100644 --- a/tests/system/Test/FabricatorTest.php +++ b/tests/system/Test/FabricatorTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Model; use PHPUnit\Framework\Attributes\Group; +use stdClass; use Tests\Support\Models\EntityModel; use Tests\Support\Models\EventModel; use Tests\Support\Models\FabricatorModel; @@ -30,6 +31,8 @@ final class FabricatorTest extends CIUnitTestCase { /** * Default formatters to use for UserModel. Should match detected version. + * + * @var array */ private array $formatters = [ 'name' => 'name', @@ -128,8 +131,10 @@ public function testGetFakerReturnsUsableGenerator(): void $fabricator = new Fabricator(UserModel::class); $faker = $fabricator->getFaker(); + $digit = $faker->randomDigit(); - $this->assertIsNumeric($faker->randomDigit()); + $this->assertGreaterThanOrEqual(0, $digit); + $this->assertLessThanOrEqual(9, $digit); } public function testSetFormattersChangesFormatters(): void @@ -408,6 +413,7 @@ public function testCreateMockSetsDatabaseFields(): void $fabricator = new Fabricator(FabricatorModel::class); $result = $fabricator->create(null, true); + $this->assertInstanceOf(stdClass::class, $result); $this->assertIsInt($result->id); $this->assertIsInt($result->created_at); diff --git a/tests/system/Test/FeatureTestTraitTest.php b/tests/system/Test/FeatureTestTraitTest.php index 79a00104a079..563815314a6c 100644 --- a/tests/system/Test/FeatureTestTraitTest.php +++ b/tests/system/Test/FeatureTestTraitTest.php @@ -358,6 +358,9 @@ public function testOpenCliRoutesFromHttpGot404(string $from, string $to, string $this->get($httpGet); } + /** + * @return iterable + */ public static function provideOpenCliRoutesFromHttpGot404(): iterable { return [ diff --git a/tests/system/Test/FilterTestTraitTest.php b/tests/system/Test/FilterTestTraitTest.php index ff74e029be31..3343be2e47a1 100644 --- a/tests/system/Test/FilterTestTraitTest.php +++ b/tests/system/Test/FilterTestTraitTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Test; +use Closure; use CodeIgniter\HTTP\RequestInterface; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Filters\Customfilter; @@ -53,8 +54,7 @@ public function testGetCallerReturnsClosure(): void { $caller = $this->getFilterCaller('test-customfilter', 'before'); - $this->assertIsCallable($caller); - $this->assertInstanceOf('Closure', $caller); + $this->assertInstanceOf(Closure::class, $caller); } public function testGetCallerInvalidPosition(): void diff --git a/tests/system/Test/TestCaseTest.php b/tests/system/Test/TestCaseTest.php index 6905d825533a..611c3fe0ebd4 100644 --- a/tests/system/Test/TestCaseTest.php +++ b/tests/system/Test/TestCaseTest.php @@ -93,4 +93,29 @@ public function testCloseEnoughStringBadLength(): void $result = $this->assertCloseEnoughString('apples & oranges', 'apples'); $this->assertFalse($result, 'Different string lengths should have returned false'); } + + public function testTearDownResetsIsWindowsMock(): void + { + $testCase = new class ('test') extends CIUnitTestCase { + protected $tearDownMethods = ['customTearDown']; + + protected function customTearDown(): void + { + } + + public function triggerTearDown(): void + { + $this->tearDown(); + } + }; + + $default = DIRECTORY_SEPARATOR === '\\'; + + is_windows(! $default); + $this->assertSame(! $default, is_windows()); + + $testCase->triggerTearDown(); + + $this->assertSame($default, is_windows()); + } } diff --git a/tests/system/Test/TestLoggerTest.php b/tests/system/Test/TestLoggerTest.php index 4c9f38df0dad..7999ee5364bc 100644 --- a/tests/system/Test/TestLoggerTest.php +++ b/tests/system/Test/TestLoggerTest.php @@ -34,6 +34,9 @@ public function testDidLogMethod(bool $expected, string $level, string $message, ); } + /** + * @return iterable + */ public static function provideDidLogMethod(): iterable { yield 'exact' => [ diff --git a/tests/system/Test/TestResponseTest.php b/tests/system/Test/TestResponseTest.php index dc175897b870..c79104e43e72 100644 --- a/tests/system/Test/TestResponseTest.php +++ b/tests/system/Test/TestResponseTest.php @@ -29,11 +29,6 @@ final class TestResponseTest extends CIUnitTestCase private ?TestResponse $testResponse = null; private Response $response; - protected function setUp(): void - { - parent::setUp(); - } - #[DataProvider('provideHttpStatusCodes')] public function testIsOK(int $code, bool $isOk): void { @@ -45,6 +40,8 @@ public function testIsOK(int $code, bool $isOk): void /** * Provides status codes and their expected "OK" + * + * @return iterable */ public static function provideHttpStatusCodes(): iterable { @@ -432,6 +429,10 @@ public function testAssertJsonExactString(): void $this->testResponse->assertJSONExact($formatter->format($data)); } + /** + * @param array $responseOptions + * @param array $headers + */ protected function getTestResponse(?string $body = null, array $responseOptions = [], array $headers = []): void { $this->response = new Response(new App()); diff --git a/tests/system/Throttle/ThrottleTest.php b/tests/system/Throttle/ThrottleTest.php index 655cc21dfcfa..b16606341ab7 100644 --- a/tests/system/Throttle/ThrottleTest.php +++ b/tests/system/Throttle/ThrottleTest.php @@ -39,7 +39,8 @@ public function testTokenTime(): void $throttler = new Throttler($this->cache); // tokenTime should be 0 to start - $this->assertSame(0, $throttler->getTokenTime()); + $tokenTime = $throttler->getTokenTime(); + $this->assertSame(0, $tokenTime); // set $rate $rate = 1; // allow 1 request per minute @@ -191,6 +192,9 @@ public function testFlooding(): void $this->assertEqualsWithDelta(10.0, round($this->cache->get('throttler_127.0.0.1')), PHP_FLOAT_EPSILON); } + /** + * @param list $checkInputs + */ #[DataProvider('provideTokenTimeCalculationUCs')] public function testTokenTimeCalculationUCs(int $capacity, int $seconds, array $checkInputs): void { @@ -209,6 +213,13 @@ public function testTokenTimeCalculationUCs(int $capacity, int $seconds, array $ } } + /** + * @return iterable + * }> + */ public static function provideTokenTimeCalculationUCs(): iterable { return [ diff --git a/tests/system/Validation/CreditCardRulesTest.php b/tests/system/Validation/CreditCardRulesTest.php index e578c0b4d980..50c0bfecf50c 100644 --- a/tests/system/Validation/CreditCardRulesTest.php +++ b/tests/system/Validation/CreditCardRulesTest.php @@ -26,7 +26,7 @@ final class CreditCardRulesTest extends StrictCreditCardRulesTest protected Validation $validation; /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ diff --git a/tests/system/Validation/FileRulesTest.php b/tests/system/Validation/FileRulesTest.php index 2b43768e1ac8..a5153cd26868 100644 --- a/tests/system/Validation/FileRulesTest.php +++ b/tests/system/Validation/FileRulesTest.php @@ -26,7 +26,7 @@ final class FileRulesTest extends StrictFileRulesTest protected Validation $validation; /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ diff --git a/tests/system/Validation/FormatRulesTest.php b/tests/system/Validation/FormatRulesTest.php index b7d369e754a2..c2b6e0ca480c 100644 --- a/tests/system/Validation/FormatRulesTest.php +++ b/tests/system/Validation/FormatRulesTest.php @@ -33,7 +33,7 @@ class FormatRulesTest extends CIUnitTestCase protected Validation $validation; /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ @@ -160,6 +160,9 @@ public function testValidURLStrict(?string $url, bool $isLoose, bool $isStrict): $this->assertSame($isStrict, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideValidUrl(): iterable { yield from [ @@ -286,6 +289,9 @@ public function testValidEmail(?string $email, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideValidEmail(): iterable { yield from [ @@ -318,6 +324,9 @@ public function testValidEmails(?string $email, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideValidEmails(): iterable { yield from [ @@ -362,6 +371,9 @@ public function testValidIP(?string $ip, ?string $which, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideValidIP(): iterable { yield from [ @@ -430,6 +442,9 @@ public function testString($str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideString(): iterable { yield from [ @@ -462,6 +477,9 @@ public function testAlpha(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideAlpha(): iterable { yield from [ @@ -502,6 +520,9 @@ public function testAlphaSpace(?string $value, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideAlphaSpace(): iterable { yield from [ @@ -546,6 +567,9 @@ public function testAlphaNumeric(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function alphaNumericProvider(): iterable { yield from [ @@ -582,6 +606,9 @@ public function testAlphaNumericPunct(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideAlphaNumericPunct(): iterable { yield from [ @@ -710,6 +737,9 @@ public function testAlphaDash(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideAlphaDash(): iterable { yield from [ @@ -746,6 +776,9 @@ public function testHex(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideHex(): iterable { yield from [ @@ -782,6 +815,9 @@ public function testNumeric(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideNumeric(): iterable { yield from [ @@ -856,6 +892,9 @@ public function testNumericWithInvalidTypeData($value, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideInvalidIntegerType(): iterable { // TypeError : CodeIgniter\Validation\FormatRules::integer(): Argument #1 ($str) must be of type ?string, array given @@ -900,6 +939,9 @@ public function testInteger(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideInteger(): iterable { yield from [ @@ -952,6 +994,9 @@ public function testDecimal(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideDecimal(): iterable { yield from [ @@ -1008,6 +1053,9 @@ public function testNatural(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideNatural(): iterable { yield from [ @@ -1048,6 +1096,9 @@ public function testNaturalNoZero(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideNaturalNoZero(): iterable { yield from [ @@ -1088,6 +1139,9 @@ public function testBase64(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideBase64(): iterable { yield from [ @@ -1124,6 +1178,9 @@ public function testJson(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideJson(): iterable { yield from [ @@ -1180,6 +1237,9 @@ public function testTimeZone(?string $str, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideTimeZone(): iterable { yield from [ @@ -1216,6 +1276,9 @@ public function testValidDate(?string $str, ?string $format, bool $expected): vo $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideValidDate(): iterable { yield from [ diff --git a/tests/system/Validation/RulesTest.php b/tests/system/Validation/RulesTest.php index 288a6d247e50..d4b71bd0a5f6 100644 --- a/tests/system/Validation/RulesTest.php +++ b/tests/system/Validation/RulesTest.php @@ -31,7 +31,7 @@ class RulesTest extends CIUnitTestCase protected Validation $validation; /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ @@ -59,6 +59,9 @@ protected function setUp(): void $this->validation->reset(); } + /** + * @param array $data + */ #[DataProvider('provideRequired')] public function testRequired(array $data, bool $expected): void { @@ -66,6 +69,9 @@ public function testRequired(array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable, bool}> + */ public static function provideRequired(): iterable { yield from [ @@ -79,6 +85,10 @@ public static function provideRequired(): iterable ]; } + /** + * @param array|string> $rules + * @param array $data + */ #[DataProvider('provideIfExist')] public function testIfExist(array $rules, array $data, bool $expected): void { @@ -86,6 +96,9 @@ public function testIfExist(array $rules, array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable|string>, array, bool}> + */ public static function provideIfExist(): iterable { yield from [ @@ -143,6 +156,10 @@ public function testIfExistArray(): void $this->validation->run($data); } + /** + * @param array|string> $rules + * @param array $data + */ #[DataProvider('providePermitEmpty')] public function testPermitEmpty(array $rules, array $data, bool $expected): void { @@ -150,6 +167,9 @@ public function testPermitEmpty(array $rules, array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable|string>, array, bool}> + */ public static function providePermitEmpty(): iterable { yield from [ @@ -310,6 +330,9 @@ public static function providePermitEmpty(): iterable ]; } + /** + * @param array $data + */ #[DataProvider('provideMatches')] public function testMatches(array $data, bool $expected): void { @@ -317,6 +340,9 @@ public function testMatches(array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable, bool}> + */ public static function provideMatches(): iterable { yield from [ @@ -332,6 +358,9 @@ public static function provideMatches(): iterable ]; } + /** + * @param array $data + */ #[DataProvider('provideMatchesNestedCases')] public function testMatchesNested(array $data, bool $expected): void { @@ -383,6 +412,9 @@ public function testMatchesWithDotArrayFail(): void ); } + /** + * @param array $data + */ #[DataProvider('provideDiffers')] public function testDiffers(array $data, bool $expected): void { @@ -390,6 +422,9 @@ public function testDiffers(array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable, bool}> + */ public static function provideDiffers(): iterable { yield from [ @@ -405,6 +440,9 @@ public static function provideDiffers(): iterable ]; } + /** + * @param array $data + */ #[DataProvider('provideMatchesNestedCases')] public function testDiffersNested(array $data, bool $expected): void { @@ -412,6 +450,9 @@ public function testDiffersNested(array $data, bool $expected): void $this->assertSame(! $expected, $this->validation->run($data)); } + /** + * @return iterable, bool}> + */ public static function provideMatchesNestedCases(): iterable { yield from [ @@ -464,6 +505,9 @@ public function testDiffersWithDotArrayFail(): void ); } + /** + * @param array $data + */ #[DataProvider('provideEquals')] public function testEquals(array $data, string $param, bool $expected): void { @@ -471,6 +515,9 @@ public function testEquals(array $data, string $param, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable, string, bool}> + */ public static function provideEquals(): iterable { yield from [ @@ -489,6 +536,9 @@ public function testMinLength(?string $data, string $length, bool $expected): vo $this->assertSame($expected, $this->validation->run(['foo' => $data])); } + /** + * @return iterable + */ public static function provideMinLengthCases(): iterable { yield from [ @@ -522,6 +572,9 @@ public function testExactLength($data, bool $expected): void $this->assertSame($expected, $this->validation->run(['foo' => $data])); } + /** + * @return iterable + */ public static function provideExactLength(): iterable { yield from [ @@ -550,6 +603,9 @@ public function testGreaterThan(?string $first, ?string $second, bool $expected) $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideGreaterThan(): iterable { yield from [ @@ -573,6 +629,9 @@ public function testGreaterThanEqual(?string $first, ?string $second, bool $expe $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideGreaterThanEqual(): iterable { yield from [ @@ -597,6 +656,9 @@ public function testLessThan(?string $first, ?string $second, bool $expected): v $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideLessThan(): iterable { yield from [ @@ -621,6 +683,9 @@ public function testLessThanEqual(?string $first, ?string $second, bool $expecte $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideLessThanEqual(): iterable { yield from [ @@ -653,6 +718,9 @@ public function testNotInList(?string $first, ?string $second, bool $expected): $this->assertSame(! $expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideInList(): iterable { yield from [ @@ -687,6 +755,9 @@ public function testRequiredWith(string $field, ?string $check, bool $expected): $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideRequiredWith(): iterable { yield from [ @@ -719,8 +790,9 @@ public static function provideRequiredWith(): iterable ]; } + /** @see https://github.com/codeigniter4/CodeIgniter4/issues/7557 */ /** - * @see https://github.com/codeigniter4/CodeIgniter4/issues/7557 + * @param array $data */ #[DataProvider('provideRequiredWithAndOtherRules')] public function testRequiredWithAndOtherRules(bool $expected, array $data): void @@ -734,6 +806,9 @@ public function testRequiredWithAndOtherRules(bool $expected, array $data): void $this->assertSame($expected, $result); } + /** + * @return iterable}> + */ public static function provideRequiredWithAndOtherRules(): iterable { yield from [ @@ -748,14 +823,17 @@ public static function provideRequiredWithAndOtherRules(): iterable [true, ['mustBeADate' => []]], // `otherField` and `mustBeADate` exist [true, ['mustBeADate' => '', 'otherField' => '']], - [true, ['mustBeADate' => '2023-06-12', 'otherField' => 'exists']], - [true, ['mustBeADate' => '2023-06-12', 'otherField' => '']], + [true, ['mustBeADate' => '2023-06-12', 'otherField' => 'exists']], + [true, ['mustBeADate' => '2023-06-12', 'otherField' => '']], [false, ['mustBeADate' => '', 'otherField' => 'exists']], [false, ['mustBeADate' => [], 'otherField' => 'exists']], [false, ['mustBeADate' => null, 'otherField' => 'exists']], ]; } + /** + * @param array $data + */ #[DataProvider('provideRequiredWithAndOtherRuleWithValueZero')] public function testRequiredWithAndOtherRuleWithValueZero(bool $expected, array $data): void { @@ -769,11 +847,14 @@ public function testRequiredWithAndOtherRuleWithValueZero(bool $expected, array $this->assertSame($expected, $result); } + /** + * @return iterable}> + */ public static function provideRequiredWithAndOtherRuleWithValueZero(): iterable { yield from [ [true, ['married' => '0', 'partner_name' => '']], - [true, ['married' => '1', 'partner_name' => 'Foo']], + [true, ['married' => '1', 'partner_name' => 'Foo']], [false, ['married' => '1', 'partner_name' => '']], ]; } @@ -797,6 +878,9 @@ public function testRequiredWithout(string $field, ?string $check, bool $expecte $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideRequiredWithout(): iterable { yield from [ @@ -841,6 +925,9 @@ public function testRequiredWithoutMultiple(string $foo, string $bar, string $ba $this->assertSame($result, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideRequiredWithoutMultiple(): iterable { yield from [ @@ -877,6 +964,9 @@ public static function provideRequiredWithoutMultiple(): iterable ]; } + /** + * @param array $data + */ #[DataProvider('provideRequiredWithoutMultipleWithoutFields')] public function testRequiredWithoutMultipleWithoutFields(array $data, bool $result): void { @@ -885,6 +975,9 @@ public function testRequiredWithoutMultipleWithoutFields(array $data, bool $resu $this->assertSame($result, $this->validation->run($data)); } + /** + * @return iterable, bool}> + */ public static function provideRequiredWithoutMultipleWithoutFields(): iterable { yield from [ @@ -917,6 +1010,10 @@ public static function provideRequiredWithoutMultipleWithoutFields(): iterable ]; } + /** + * @param array|string> $rules + * @param array $data + */ #[DataProvider('provideFieldExists')] public function testFieldExists(array $rules, array $data, bool $expected): void { @@ -924,6 +1021,9 @@ public function testFieldExists(array $rules, array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable|string>, array, bool}> + */ public static function provideFieldExists(): iterable { // Do not use `foo`, because there is a lang file `Foo`, and diff --git a/tests/system/Validation/StrictRules/CreditCardRulesTest.php b/tests/system/Validation/StrictRules/CreditCardRulesTest.php index a1bc09aa6c0f..55a880839662 100644 --- a/tests/system/Validation/StrictRules/CreditCardRulesTest.php +++ b/tests/system/Validation/StrictRules/CreditCardRulesTest.php @@ -30,7 +30,7 @@ class CreditCardRulesTest extends CIUnitTestCase protected Validation $validation; /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ @@ -69,6 +69,8 @@ public function testValidCCNumber(string $type, ?string $number, bool $expected) * Cards shown are test cards found around the web. * * @see https://www.paypalobjects.com/en_US/vhelp/paypalmanager_help/credit_card_numbers.htm + * + * @return iterable */ public static function provideValidCCNumber(): iterable { @@ -98,6 +100,16 @@ public static function provideValidCCNumber(): iterable 'abcd efgh ijkl mnop', false, ], + 'decimal_point_visa' => [ + 'visa', + '41.1111111111111', + false, + ], + 'decimal_point_mastercard' => [ + 'mastercard', + '5351367.37861108', + false, + ], 'bad_length' => [ 'amex', '3782 8224 6310 0051', diff --git a/tests/system/Validation/StrictRules/FileRulesTest.php b/tests/system/Validation/StrictRules/FileRulesTest.php index 5c58787b5f0b..b9e22ca504e6 100644 --- a/tests/system/Validation/StrictRules/FileRulesTest.php +++ b/tests/system/Validation/StrictRules/FileRulesTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Validation\Validation; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Validation\TestRules; @@ -30,7 +31,7 @@ class FileRulesTest extends CIUnitTestCase protected Validation $validation; /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ @@ -179,6 +180,79 @@ public function testUploadedArrayReturnsFalse(): void $this->assertFalse($this->validation->run([])); } + #[DataProvider('provideOptionalMultipleUploadChecksFilesAfterEmptyEntry')] + public function testOptionalMultipleUploadChecksFilesAfterEmptyEntry(string $rule): void + { + $this->setOptionalMultipleUpload('wrong.txt'); + + $this->validation->setRules(['files' => $rule]); + + $this->assertFalse($this->validation->run([])); + } + + /** + * @return iterable + */ + public static function provideOptionalMultipleUploadChecksFilesAfterEmptyEntry(): iterable + { + yield 'max_size' => ['max_size[files,0]']; + + yield 'is_image' => ['is_image[files]']; + + yield 'mime_in' => ['mime_in[files,image/gif]']; + + yield 'ext_in' => ['ext_in[files,png]']; + + yield 'max_dims' => ['max_dims[files,1,1]']; + + yield 'min_dims' => ['min_dims[files,800,600]']; + } + + public function testOptionalMultipleUploadAllowsValidFileAfterEmptyEntry(): void + { + $this->setOptionalMultipleUpload('my-avatar.png'); + + $this->validation->setRules([ + 'files' => 'max_size[files,100]|is_image[files]|mime_in[files,image/png]' + . '|ext_in[files,png]|max_dims[files,640,480]|min_dims[files,320,240]', + ]); + + $this->assertTrue($this->validation->run([])); + } + + public function testOptionalMultipleUploadAllowsEmptyEntries(): void + { + $this->setOptionalMultipleUpload(); + + $this->validation->setRules([ + 'files' => 'max_size[files,100]|is_image[files]|mime_in[files,image/png]' + . '|ext_in[files,png]|max_dims[files,640,480]|min_dims[files,320,240]', + ]); + + $this->assertTrue($this->validation->run([])); + } + + private function setOptionalMultipleUpload(?string $filename = null): void + { + $upload = [ + 'tmp_name' => [''], + 'name' => [''], + 'size' => [0], + 'type' => [''], + 'error' => [UPLOAD_ERR_NO_FILE], + ]; + + if ($filename !== null) { + $upload['tmp_name'][] = TESTPATH . '_support/Validation/uploads/phpUxc0ty'; + $upload['name'][] = $filename; + $upload['size'][] = 4614; + $upload['type'][] = 'image/png'; + $upload['error'][] = UPLOAD_ERR_OK; + } + + service('superglobals')->setFilesArray(['files' => $upload]); + } + public function testMaxSize(): void { $this->validation->setRules(['avatar' => 'max_size[avatar,100]']); @@ -260,6 +334,67 @@ public function testMinDimsBad(): void $this->assertFalse($this->validation->run([])); } + #[DataProvider('provideDimensionsFailOnUploadError')] + public function testDimensionsFailOnUploadError(string $rule, int $error): void + { + service('superglobals')->setFilesArray([ + 'files' => [ + 'tmp_name' => '', + 'name' => 'my-avatar.png', + 'size' => 0, + 'type' => '', + 'error' => $error, + ], + ]); + + $this->validation->setRules(['files' => $rule]); + + $this->assertFalse($this->validation->run([])); + } + + #[DataProvider('provideDimensionsFailOnUploadError')] + public function testDimensionsFailOnUploadErrorAfterEmptyAndValidEntries(string $rule, int $error): void + { + service('superglobals')->setFilesArray([ + 'files' => [ + 'tmp_name' => [ + '', + TESTPATH . '_support/Validation/uploads/phpUxc0ty', + TESTPATH . '_support/Validation/uploads/phpUxc0ty', + ], + 'name' => ['', 'my-avatar.png', 'my-photo.png'], + 'size' => [0, 4614, 4614], + 'type' => ['', 'image/png', 'image/png'], + 'error' => [UPLOAD_ERR_NO_FILE, UPLOAD_ERR_OK, $error], + ], + ]); + + $this->validation->setRules(['files' => $rule]); + + $this->assertFalse($this->validation->run([])); + } + + /** + * @return iterable + */ + public static function provideDimensionsFailOnUploadError(): iterable + { + $errors = [ + 'ini size' => UPLOAD_ERR_INI_SIZE, + 'form size' => UPLOAD_ERR_FORM_SIZE, + 'partial' => UPLOAD_ERR_PARTIAL, + 'no tmp dir' => UPLOAD_ERR_NO_TMP_DIR, + 'cant write' => UPLOAD_ERR_CANT_WRITE, + 'extension' => UPLOAD_ERR_EXTENSION, + ]; + + foreach (['max_dims[files,640,480]', 'min_dims[files,320,240]'] as $rule) { + foreach ($errors as $name => $error) { + yield $rule . ' ' . $name => [$rule, $error]; + } + } + } + public function testIsImage(): void { $this->validation->setRules(['avatar' => 'is_image[avatar]']); @@ -525,6 +660,92 @@ public function testExtensionFailsForExtensionlessClientFilename(): void } } + #[DataProvider('provideFileRulesRejectUnsafeClientFilename')] + public function testFileRulesRejectUnsafeClientFilename(string $name, string $rule): void + { + $payload = $this->createGifPhpPayload(); + + try { + $this->setUploadedAvatar($payload, $name); + + $this->validation->setRules(['avatar' => $rule]); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + /** + * @return iterable + */ + public static function provideFileRulesRejectUnsafeClientFilename(): iterable + { + $names = [ + 'shell.php.', + 'shell.php..', + 'shell.php. ', + 'shell.gif.', + 'shell.gif. ', + 'shell.php.gif', + 'shell.PHP.gif', + 'shell.p$hp.gif', + 'shell.php;.gif', + 'shell.PH#P.gif', + 'shell.p%20hp.gif', + "shell.p\x01hp.gif", + 'shell.php8.gif', + 'shell.pht.gif', + 'shell.phtml.gif', + 'shell.phar.gif', + 'shell.phar&.gif', + 'shell.phps.gif', + '.php.gif', + '.phar.gif', + '.phps.gif', + ]; + + $rules = [ + 'is_image[avatar]', + 'mime_in[avatar,image/gif]', + 'ext_in[avatar,gif]', + ]; + + foreach ($names as $name) { + foreach ($rules as $rule) { + yield json_encode($name) . ' ' . $rule => [$name, $rule]; + } + } + } + + #[DataProvider('provideFileRulesAllowSafeDottedClientFilename')] + public function testFileRulesAllowSafeDottedClientFilename(string $name, string $rule): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, $name); + + $this->validation->setRules(['avatar' => $rule]); + $this->assertTrue($this->validation->run([])); + } finally { + unlink($payload); + } + } + + /** + * @return iterable + */ + public static function provideFileRulesAllowSafeDottedClientFilename(): iterable + { + foreach (['family.vacation.gif', 'family.va$ca%20tion.gif', 'php.gif', 'p$hp.gif'] as $name) { + yield $name . ' is_image[avatar]' => [$name, 'is_image[avatar]']; + + yield $name . ' mime_in[avatar,image/gif]' => [$name, 'mime_in[avatar,image/gif]']; + + yield $name . ' ext_in[avatar,gif]' => [$name, 'ext_in[avatar,gif]']; + } + } + private function createGifPayload(): string { $payload = tempnam(sys_get_temp_dir(), 'ci4-upload-poc-'); @@ -538,6 +759,15 @@ private function createGifPayload(): string return $payload; } + private function createGifPhpPayload(): string + { + $payload = $this->createGifPayload(); + + file_put_contents($payload, "\n\n", FILE_APPEND); + + return $payload; + } + private function createPhpPayload(): string { $payload = tempnam(sys_get_temp_dir(), 'ci4-upload-poc-'); diff --git a/tests/system/Validation/StrictRules/FormatRulesTest.php b/tests/system/Validation/StrictRules/FormatRulesTest.php index 92d12ad8ad1a..db7372d8179c 100644 --- a/tests/system/Validation/StrictRules/FormatRulesTest.php +++ b/tests/system/Validation/StrictRules/FormatRulesTest.php @@ -24,7 +24,7 @@ final class FormatRulesTest extends TraditionalFormatRulesTest { /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ diff --git a/tests/system/Validation/StrictRules/RulesTest.php b/tests/system/Validation/StrictRules/RulesTest.php index fc9a771715c8..9cb5744a285d 100644 --- a/tests/system/Validation/StrictRules/RulesTest.php +++ b/tests/system/Validation/StrictRules/RulesTest.php @@ -28,7 +28,7 @@ final class RulesTest extends TraditionalRulesTest protected Validation $validation; /** - * @var array|string>> + * @var array|string>> */ protected array $config = [ 'ruleSets' => [ @@ -48,6 +48,10 @@ final class RulesTest extends TraditionalRulesTest ], ]; + /** + * @param array|string> $rules + * @param array $data + */ #[DataProvider('providePermitEmptyStrict')] public function testPermitEmptyStrict(array $rules, array $data, bool $expected): void { @@ -55,6 +59,9 @@ public function testPermitEmptyStrict(array $rules, array $data, bool $expected) $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable|string>, array, bool}> + */ public static function providePermitEmptyStrict(): iterable { yield from [ @@ -114,6 +121,9 @@ public function testGreaterThanEqualStrict($value, string $param, bool $expected $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideGreaterThanEqualStrict(): iterable { yield from [ @@ -139,6 +149,9 @@ public function testGreaterThanStrict($value, string $param, bool $expected): vo $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideGreaterThanStrict(): iterable { yield from [ @@ -165,6 +178,9 @@ public function testLessThanStrict($value, string $param, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideLessThanStrict(): iterable { yield from [ @@ -192,6 +208,9 @@ public function testLessEqualThanStrict($value, ?string $param, bool $expected): $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideLessEqualThanStrict(): iterable { yield from [ @@ -205,6 +224,9 @@ public static function provideLessEqualThanStrict(): iterable ]; } + /** + * @param array $data + */ #[DataProvider('provideMatches')] public function testMatches(array $data, bool $expected): void { @@ -212,6 +234,9 @@ public function testMatches(array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable, bool}> + */ public static function provideMatches(): iterable { yield from [ @@ -227,6 +252,9 @@ public static function provideMatches(): iterable ]; } + /** + * @param array $data + */ #[DataProvider('provideDiffers')] public function testDiffers(array $data, bool $expected): void { @@ -234,6 +262,9 @@ public function testDiffers(array $data, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable, bool}> + */ public static function provideDiffers(): iterable { yield from [ diff --git a/tests/system/Validation/StrictRules/ValidationTest.php b/tests/system/Validation/StrictRules/ValidationTest.php index 9473cd9f6b32..afff6c8f6a64 100644 --- a/tests/system/Validation/StrictRules/ValidationTest.php +++ b/tests/system/Validation/StrictRules/ValidationTest.php @@ -24,7 +24,7 @@ final class ValidationTest extends TraditionalValidationTest { /** - * @var array|string>|string>|string> + * @var array|string>|string>|string> */ protected static array $config = [ 'ruleSets' => [ diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 7cf70deaeb27..49baeaba0c79 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -40,7 +40,7 @@ class ValidationTest extends CIUnitTestCase protected Validation $validation; /** - * @var array|string>|string>|string> + * @var array|string>|string>|string> */ protected static array $config = [ 'ruleSets' => [ @@ -213,6 +213,9 @@ public function testSetRuleRulesFormat(bool $expected, $rules): void $this->addToAssertionCount(1); } + /** + * @return iterable + */ public static function provideSetRuleRulesFormat(): iterable { yield 'fail-simple-object' => [ @@ -403,6 +406,8 @@ public function testCallableRule(): void /** * Validation rule2 + * + * @param array $data */ public function rule2(mixed $value, array $data, ?string &$error, string $field): bool { @@ -473,6 +478,9 @@ public function testCanValidatetArrayData($value, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideCanValidatetArrayData(): iterable { yield 'list array' => [ @@ -524,6 +532,9 @@ public function testIsIntWithInvalidTypeData($value, bool $expected): void $this->assertSame($expected, $this->validation->run($data)); } + /** + * @return iterable + */ public static function provideIsIntWithInvalidTypeData(): iterable { yield 'array with int' => [ @@ -594,13 +605,13 @@ public function testSetRuleWithCustomErrors(): void $this->validation->setRule( 'foo', 'Foo', - ['foo' => 'is_numeric'], + ['foo' => 'is_numeric'], ['is_numeric' => 'Nope. Not a number.'], ); $this->validation->setRule( 'bar', 'Bar', - ['bar' => 'is_numeric'], + ['bar' => 'is_numeric'], ['is_numeric' => 'Nope. Not a number.'], ); $result = $this->validation->run($data); @@ -752,6 +763,11 @@ public function testRunWithCustomErrorsAndAsteriskField(): void * @param list|string $rules * @param string $expected */ + /** + * @param array|string> $errors + * @param mixed $rules + * @param mixed $expected + */ #[DataProvider('provideRulesSetup')] public function testRulesSetup($rules, $expected, array $errors = []): void { @@ -762,6 +778,9 @@ public function testRulesSetup($rules, $expected, array $errors = []): void $this->assertSame($expected, $this->validation->getError('foo')); } + /** + * @return iterable|string, string, array|string>}> + */ public static function provideRulesSetup(): iterable { yield from [ @@ -1054,9 +1073,9 @@ public function testShowBadTemplate(): void public function testNoRuleSetsSetup(): void { - try { - $rulesets = static::$config['ruleSets']; + $rulesets = static::$config['ruleSets']; + try { static::$config['ruleSets'] = null; (new Validation((object) static::$config, service('renderer'))) ->reset() @@ -1188,6 +1207,11 @@ public function testRulesForObjectField(): void ], $this->validation->getErrors()); } + /** + * @param array $body + * @param array|string> $rules + * @param array $results + */ #[DataProvider('provideRulesForArrayField')] public function testRulesForArrayField(array $body, array $rules, array $results): void { @@ -1201,6 +1225,9 @@ public function testRulesForArrayField(array $body, array $rules, array $results $this->assertSame($results, $this->validation->getErrors()); } + /** + * @return iterable, rules: array|string>, results: array}> + */ public static function provideRulesForArrayField(): iterable { yield from [ @@ -1450,8 +1477,10 @@ public function testTranslatedLabelTagReplacement(): void $this->assertSame($expected, $errors['Username']); } + /** @see https://github.com/codeigniter4/CodeIgniter4/issues/4521 */ /** - * @see https://github.com/codeigniter4/CodeIgniter4/issues/4521 + * @param array|string> $rules + * @param array $data */ #[DataProvider('provideIfExistRuleWithAsterisk')] public function testIfExistRuleWithAsterisk(bool $expected, array $rules, array $data): void @@ -1460,18 +1489,21 @@ public function testIfExistRuleWithAsterisk(bool $expected, array $rules, array $this->assertSame($expected, $actual); } + /** + * @return iterable|string>, array}> + */ public static function provideIfExistRuleWithAsterisk(): iterable { yield 'dot-on-end-fail' => [ false, ['status.*' => 'if_exist|in_list[status_1,status_2]'], - ['status' => ['bad-status']], + ['status' => ['bad-status']], ]; yield 'dot-on-end-pass' => [ true, ['status.*' => 'if_exist|in_list[status_1,status_2]'], - ['status' => ['status_1']], + ['status' => ['status_1']], ]; yield 'dot-on-middle-fail' => [ @@ -1519,8 +1551,10 @@ public static function provideIfExistRuleWithAsterisk(): iterable ]; } + /** @see https://github.com/codeigniter4/CodeIgniter4/issues/4510 */ /** - * @see https://github.com/codeigniter4/CodeIgniter4/issues/4510 + * @param array|string> $rules + * @param array $data */ #[DataProvider('provideValidationOfArrayData')] public function testValidationOfArrayData(bool $expected, array $rules, array $data): void @@ -1529,6 +1563,9 @@ public function testValidationOfArrayData(bool $expected, array $rules, array $d $this->assertSame($expected, $actual); } + /** + * @return iterable|string>, array}> + */ public static function provideValidationOfArrayData(): iterable { yield 'fail-empty-string' => [ @@ -1574,8 +1611,9 @@ public static function provideValidationOfArrayData(): iterable ]; } + /** @see https://github.com/codeigniter4/CodeIgniter4/issues/4929 */ /** - * @see https://github.com/codeigniter4/CodeIgniter4/issues/4929 + * @param array $expected */ #[DataProvider('provideSplittingOfComplexStringRules')] public function testSplittingOfComplexStringRules(string $input, array $expected): void @@ -1584,6 +1622,9 @@ public function testSplittingOfComplexStringRules(string $input, array $expected $this->assertSame($expected, $splitter($input)); } + /** + * @return iterable}> + */ public static function provideSplittingOfComplexStringRules(): iterable { yield [ @@ -1636,15 +1677,13 @@ public static function provideSplittingOfComplexStringRules(): iterable * internal method to simplify placeholder replacement test * REQUIRES THE RULES TO BE SET FOR THE FIELD "foo" * - * @param array|null $data optional POST data, needs to contain the key $placeholderField to pass + * @param array|null $data Optional POST data, needs to contain the key $placeholderField to pass * * @source https://github.com/codeigniter4/CodeIgniter4/pull/3910#issuecomment-784922913 */ protected function placeholderReplacementResultDetermination(string $placeholder = 'id', ?array $data = null): void { - if ($data === null) { - $data = [$placeholder => '12']; - } + $data ??= [$placeholder => '12']; $validationRules = self::getPrivateMethodInvoker($this->validation, 'fillPlaceholders')($this->validation->getRules(), $data); $fieldRules = $validationRules['foo']['rules'] ?? $validationRules['foo']; diff --git a/tests/system/View/ControlledCellTest.php b/tests/system/View/ControlledCellTest.php index 96e3be2e3115..8215993ca07b 100644 --- a/tests/system/View/ControlledCellTest.php +++ b/tests/system/View/ControlledCellTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\View\Cells\Cell; use CodeIgniter\View\Exceptions\ViewException; use PHPUnit\Framework\Attributes\Group; use Tests\Support\View\Cells\AdditionCell; @@ -69,6 +70,25 @@ public function testCellThroughRenderMethodWithExtraData(): void $this->assertStringContainsString('42, 23, 16, 15, 8, 4', $result); } + public function testCellDataCannotReplaceIncludedView(): void + { + $cell = new class () extends Cell { + public function render(): string + { + $directory = __DIR__ . '/../../_support/View/Cells/'; + + return $this->view($directory . 'notice.php', [ + 'message' => 'Trusted', + 'greeting' => 'Untrusted', + 'name' => 'view', + 'foundView' => $directory . 'greeting.php', + ]); + } + }; + + $this->assertSame('
Trusted
', trim($cell->render())); + } + public function testCellThrowsExceptionWhenCannotFindTheViewFile(): void { $this->expectException(LogicException::class); diff --git a/tests/system/View/DBResultDummy.php b/tests/system/View/DBResultDummy.php index fb42f41b04b6..62ebb298b853 100644 --- a/tests/system/View/DBResultDummy.php +++ b/tests/system/View/DBResultDummy.php @@ -27,7 +27,7 @@ public function getFieldNames(): array } /** - * @return array> + * @return list> */ public function getResultArray(): array { diff --git a/tests/system/View/ParserFilterTest.php b/tests/system/View/ParserFilterTest.php index 5761d0fbb74b..3e4c4d40d9b6 100644 --- a/tests/system/View/ParserFilterTest.php +++ b/tests/system/View/ParserFilterTest.php @@ -181,12 +181,6 @@ public function testHighlightCode(): void $parser->setData($data); $template = '{ value1|highlight_code }'; - $expected = <<<'EOF' - - Sincerely  - - - EOF; // PHP 8.3 changes the output. if (PHP_VERSION_ID >= 80300) { diff --git a/tests/system/View/ParserPluginTest.php b/tests/system/View/ParserPluginTest.php index bb458a85ad04..a9312e91168c 100644 --- a/tests/system/View/ParserPluginTest.php +++ b/tests/system/View/ParserPluginTest.php @@ -36,8 +36,11 @@ protected function setUp(): void Services::reset(true); - $this->parser = service('parser'); - $this->validator = service('validation'); + $this->parser = service('parser'); + + $validator = service('validation'); + $this->assertInstanceOf(Validation::class, $validator); + $this->validator = $validator; } public function testCurrentURL(): void diff --git a/tests/system/View/ParserTest.php b/tests/system/View/ParserTest.php index fa6784445e8e..6e8dde918dfd 100644 --- a/tests/system/View/ParserTest.php +++ b/tests/system/View/ParserTest.php @@ -17,9 +17,11 @@ use CodeIgniter\Entity\Entity; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\View\Exceptions\ViewException; +use Config\Services; use Config\View as ViewConfig; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use RuntimeException; use stdClass; /** @@ -432,12 +434,13 @@ public function testMismatchedVarPair(): void #[DataProvider('provideEscHandling')] public function testEscHandling($value, $expected = null): void { - if ($expected === null) { - $expected = $value; - } + $expected ??= $value; $this->assertSame($expected, \esc($value)); } + /** + * @return iterable> + */ public static function provideEscHandling(): iterable { return [ @@ -543,20 +546,20 @@ public function testNoEscapingSetData(): void { $template = '{ foo | noescape}'; - $this->parser->setData(['foo' => 'http://foo.com'], 'unknown'); + $this->parser->setData(['foo' => 'http://foo.com'], 'unknown'); // @phpstan-ignore argument.type (deliberately invalid context) $this->assertSame('http://foo.com', $this->parser->renderString($template)); } public function testAutoEscaping(): void { - $this->parser->setData(['foo' => 'http://foo.com'], 'unknown'); + $this->parser->setData(['foo' => 'http://foo.com'], 'unknown'); // @phpstan-ignore argument.type (deliberately invalid context) $this->assertSame('html', $this->parser->shouldAddEscaping('{ foo | this | that }')); } public function testAutoEscapingNot(): void { - $this->parser->setData(['foo' => 'http://foo.com'], 'unknown'); + $this->parser->setData(['foo' => 'http://foo.com'], 'unknown'); // @phpstan-ignore argument.type (deliberately invalid context) $this->assertFalse($this->parser->shouldAddEscaping('{ foo | noescape }')); } @@ -611,6 +614,207 @@ public function testParserNoEscape(): void $this->assertSame('', $this->parser->renderString($template)); } + /** + * @param array $data + */ + #[DataProvider('provideParserDoesNotInterpretSyntaxInSubstitutionValues')] + public function testParserDoesNotInterpretSyntaxInSubstitutionValues( + string $template, + array $data, + string $expected, + ): void { + $this->parser->setData($data, 'html'); + + $this->assertSame($expected, $this->parser->renderString($template)); + } + + /** + * @return iterable, string}> + */ + public static function provideParserDoesNotInterpretSyntaxInSubstitutionValues(): iterable + { + $escapedScript = '<script>alert(document.domain)</script>'; + + yield 'top-level no-escape delimiters' => [ + '{first}|{second}', + [ + 'first' => '{!second!}', + 'second' => '', + ], + '{!second!}|' . $escapedScript, + ]; + + yield 'top-level filter' => [ + '{first}|{second}', + [ + 'first' => '{second|upper}', + 'second' => '', + ], + '{second|upper}|' . $escapedScript, + ]; + + yield 'loop no-escape delimiters' => [ + '{comments}{author}: {text}{/comments}', + [ + 'comments' => [[ + 'author' => '{!text!}', + 'text' => '', + ]], + ], + '{!text!}: ' . $escapedScript, + ]; + + yield 'loop unknown filter' => [ + '{comments}{author}: {text}{/comments}', + [ + 'comments' => [[ + 'author' => '{text|raw}', + 'text' => '', + ]], + ], + '{text|raw}: ' . $escapedScript, + ]; + + yield 'loop to top-level no-escape delimiters' => [ + '{comments}{author}{/comments}|{text}', + [ + 'comments' => [['author' => '{!text!}']], + 'text' => '', + ], + '{!text!}|' . $escapedScript, + ]; + + yield 'nested loop to outer row' => [ + '{comments}{replies}[{reply}]{/replies} by {author}{/comments}', + [ + 'comments' => [[ + 'replies' => [['reply' => '{author|raw}']], + 'author' => '', + ]], + ], + '[{author|raw}] by ' . $escapedScript, + ]; + + yield 'repeated nested pairs with different templates' => [ + '{comments}{replies}[{reply}] by {author}{/replies} again {replies}{reply} by {author}{/replies}{/comments}', + [ + 'comments' => [[ + 'replies' => [['reply' => '{author|raw}'], ['reply' => 'Second']], + 'author' => '', + ]], + ], + '[{author|raw}] by ' . $escapedScript . '[Second] by ' . $escapedScript + . ' again {author|raw} by ' . $escapedScript . 'Second by ' . $escapedScript, + ]; + + yield 'value resembling an internal token' => [ + '{first}|{second}', + [ + 'first' => "\x1A0123456789abcdef0123456789abcdef:0\x1A", + 'second' => '', + ], + "\x1A0123456789abcdef0123456789abcdef:0\x1A|" . $escapedScript, + ]; + + yield 'unescaped substitution' => [ + '{!first!}|{second}', + ['first' => '{!second!}', 'second' => ''], + '{!second!}|' . $escapedScript, + ]; + + yield 'syntax assembled across a substitution boundary' => [ + '{first}second!}|{second}', + ['first' => '{!', 'second' => ''], + '{!second!}|' . $escapedScript, + ]; + + yield 'repeated substitutions with different escaping' => [ + '{first}|{!first!}|{second}', + ['first' => '{!second!}', 'second' => ''], + '<b>{!second!}</b>|{!second!}|' . $escapedScript, + ]; + + yield 'noparse marker in data' => [ + '{first}|{noparse}literal{/noparse}', + ['first' => 'noparse_' . md5('literal')], + 'noparse_' . md5('literal') . '|literal', + ]; + } + + public function testParserDoesNotInterpretSyntaxInFilteredValues(): void + { + $this->config->filters['syntax'] = static fn (mixed $value): string => '{!' . $value . '!}'; + $this->parser->setData(['first' => 'second', 'second' => 'unsafe']); + + $this->assertSame( + '{!second!}|<b>unsafe</b>', + $this->parser->renderString('{first|syntax}|{second}'), + ); + } + + public function testParserDoesNotInterpretSyntaxWithCustomDelimiters(): void + { + $this->parser->setDelimiters('{{', '}}'); + $this->parser->setData(['first' => '{{!second!}}', 'second' => 'unsafe']); + + $this->assertSame( + '{{!second!}}|<b>unsafe</b>', + $this->parser->renderString('{{first}}|{{second}}'), + ); + } + + public function testParserDoesNotInterpretSyntaxInRawContext(): void + { + $this->parser->setData(['first' => '{!second!}'], 'raw'); + $this->parser->setData(['second' => 'unsafe'], 'html'); + + $this->assertSame( + '{!second!}|<b>unsafe</b>', + $this->parser->renderString('{first}|{second}'), + ); + } + + public function testParserRestoresReplacementStateAfterNestedRender(): void + { + $this->config->filters['nested'] = fn (mixed $value): string => $this->parser->renderString((string) $value, saveData: false); + $this->parser->setData([ + 'first' => 'before', + 'second' => '{inner}', + 'inner' => '{!last!}', + 'last' => 'unsafe', + ]); + + $this->assertSame( + 'before|{!last!}|<b>unsafe</b>', + $this->parser->renderString('{first}|{second|nested}|{last}', saveData: false), + ); + $this->assertSame([], $this->getPrivateProperty($this->parser, 'replacementTokens')); + $this->assertNull($this->getPrivateProperty($this->parser, 'replacementTokenPrefix')); + } + + public function testParserRestoresReplacementStateAfterFilterException(): void + { + $exception = new RuntimeException('Filter failed.'); + $this->config->filters['fail'] = static fn (mixed $value): never => throw $exception; + $this->parser->setData(['first' => 'before', 'second' => 'failure']); + + try { + $this->parser->renderString('{first}|{second|fail}'); + $this->fail('The filter exception was not propagated.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([], $this->getPrivateProperty($this->parser, 'replacementTokens')); + $this->assertNull($this->getPrivateProperty($this->parser, 'replacementTokenPrefix')); + + $this->parser->setData(['first' => '{!second!}', 'second' => 'unsafe']); + $this->assertSame( + '{!second!}|<b>unsafe</b>', + $this->parser->renderString('{first}|{second}'), + ); + } + public function testParserNoEscapeAndDelimiterChange(): void { $this->parser->setDelimiters('{{', '}}'); @@ -675,6 +879,41 @@ public function testIfConditionalTrue(): void $this->assertSame('HowdyWelcome', $this->parser->renderString($template)); } + public function testTemplateDataCanBeUsedInConditionals(): void + { + $this->parser->setVar('template', 'allowed'); + + $this->assertSame('matched: allowed', $this->parser->renderString('{if $template === "allowed"}matched: {template}{endif}')); + } + + public function testConditionalKeepsExistingTemplateVariableWhenNoDataOverridesIt(): void + { + $this->assertSame('present', $this->parser->renderString('{if isset($template)}present{else}missing{endif}')); + } + + public function testTemplateDataCannotReplaceEvaluatedSource(): void + { + foreach ([false, true] as $saveData) { + $config = clone $this->config; + $config->saveData = $saveData; + $parser = new Parser($config, $this->viewsDir, $this->loader); + $parser->setVar('template', ''); + + $this->assertSame('Trusted: <?php echo 6 * 7; ?>', $parser->renderString('Trusted: {template}')); + $this->assertSame($saveData, array_key_exists('template', $parser->getData())); + } + } + + public function testTemplateDataCannotReplaceFileSource(): void + { + $this->parser->setData([ + 'template' => '', + 'teststring' => 'Trusted', + ]); + + $this->assertSame("

Trusted

\n", $this->parser->render('template1')); + } + public function testElseConditionalFalse(): void { $data = [ @@ -727,12 +966,320 @@ public function testConditionalBadSyntax(): void $this->assertSame('HowdyWelcome', $this->parser->renderString($template)); } + /** + * @param array $data + */ + #[DataProvider('provideRestrictedConditionalsAllowed')] + public function testRestrictedConditionalsAllowed(string $template, array $data, string $expected): void + { + $this->parser->setData($data); + + $this->assertSame($expected, $this->parser->renderString($template, ['restrictConditionals' => true])); + } + + /** + * @return iterable, string}> + */ + public static function provideRestrictedConditionalsAllowed(): iterable + { + yield 'variable' => ['{if $vip}Yes{endif}', ['vip' => true], 'Yes']; + + yield 'negation' => ['{if !$guest}Member{endif}', ['guest' => false], 'Member']; + + yield 'single quoted string' => ["{if \$role == 'admin'}Admin{endif}", ['role' => 'admin'], 'Admin']; + + yield 'double quoted string' => ['{if $role === "admin"}Admin{endif}', ['role' => 'admin'], 'Admin']; + + yield 'not identical' => ['{if $role !== "admin"}User{endif}', ['role' => 'user'], 'User']; + + yield 'numbers' => ['{if $total >= 100 && $total < 1.5e3}Free shipping{endif}', ['total' => 150], 'Free shipping']; + + yield 'negative integer' => ['{if $total >= -10}Yes{endif}', ['total' => -5], 'Yes']; + + yield 'negative float' => ['{if $total < -1.5e2}Yes{endif}', ['total' => -200], 'Yes']; + + yield 'unary signs' => ['{if -$total === +5}Yes{endif}', ['total' => -5], 'Yes']; + + yield 'addition and subtraction' => ['{if $total + 5 - 2 === 13}Yes{endif}', ['total' => 10], 'Yes']; + + yield 'multiplication and division' => ['{if $total * 2 / 4 === 5}Yes{endif}', ['total' => 10], 'Yes']; + + yield 'modulo' => ['{if $total % 2 === 0}Even{else}Odd{endif}', ['total' => 3], 'Odd']; + + yield 'exponentiation' => ['{if $total ** 2 === 9}Yes{endif}', ['total' => 3], 'Yes']; + + yield 'arithmetic precedence' => ['{if 2 + 3 * 4 === 14 && (2 + 3) * 4 === 20}Yes{endif}', [], 'Yes']; + + yield 'constants' => ['{if $discount !== null && $vip === TRUE}Discount{endif}', ['discount' => 5, 'vip' => true], 'Discount']; + + yield 'grouping' => ['{if $total > 100 && ($country == "PL" || !$guest)}Yes{endif}', ['total' => 150, 'country' => 'DE', 'guest' => false], 'Yes']; + + yield 'wrapped in parentheses' => ['{if ($vip)}Yes{endif}', ['vip' => true], 'Yes']; + + yield 'word operators' => ['{if $vip and $guest or !$guest}Yes{else}No{endif}', ['vip' => true, 'guest' => false], 'Yes']; + + yield 'elseif and else' => ['{if $role == "admin"}A{elseif $role == "editor"}E{else}U{endif}', ['role' => 'editor'], 'E']; + } + + #[DataProvider('provideRestrictedConditionalsRejected')] + public function testRestrictedConditionalsRejected(string $template): void + { + $this->expectException(ViewException::class); + $this->expectExceptionMessage('The Parser conditional is not allowed in restricted mode:'); + + $this->parser->renderString($template, ['restrictConditionals' => true]); + } + + /** + * @return iterable + */ + public static function provideRestrictedConditionalsRejected(): iterable + { + yield 'function call' => ['{if system("id")}{endif}']; + + yield 'function call in elseif' => ['{if $vip}{elseif print("x")}{endif}']; + + yield 'function call in comparison' => ['{if count($items) > 0}{endif}']; + + yield 'call on parenthesized string' => ["{if ('system')('id')}{endif}"]; + + yield 'variable function' => ['{if $f("id")}{endif}']; + + yield 'call on constant' => ['{if true("id")}{endif}']; + + yield 'constant' => ['{if PHP_VERSION}{endif}']; + + yield 'fully qualified name' => ['{if \true}{endif}']; + + yield 'language construct' => ["{if print 'x'}{endif}"]; + + yield 'include' => ["{if include 'file.php'}{endif}"]; + + yield 'backticks' => ['{if `id`}{endif}']; + + yield 'variable variable' => ['{if $$name}{endif}']; + + yield 'this' => ['{if $this}{endif}']; + + yield 'property access' => ['{if $user->admin}{endif}']; + + yield 'array access' => ['{if $items[0]}{endif}']; + + yield 'static call' => ['{if Foo::bar()}{endif}']; + + yield 'new' => ['{if new ArrayObject}{endif}']; + + yield 'assignment' => ['{if $a = 1}{endif}']; + + yield 'arithmetic assignment' => ['{if $a += 1}{endif}']; + + yield 'exponentiation assignment' => ['{if $a **= 2}{endif}']; + + yield 'increment' => ['{if ++$a}{endif}']; + + yield 'decrement' => ['{if $a--}{endif}']; + + yield 'function call in arithmetic' => ['{if 1 + strlen("x") === 2}{endif}']; + + yield 'call on arithmetic result' => ['{if (1 + 2)("x")}{endif}']; + + yield 'concatenation' => ["{if 'a' . 'b'}{endif}"]; + + yield 'interpolated string' => ['{if "$name"}{endif}']; + + yield 'dollar brace string' => ['{if "${name}"}{endif}']; + + yield 'comment' => ['{if $a /* x */}{endif}']; + } + + public function testRestrictedConditionalsDoNotExecuteRejectedCode(): void + { + try { + $this->parser->renderString( + "{if \$vip}{endif}{if ('define')('CI_PARSER_RESTRICTED_PROBE', 1)}{endif}", + ['restrictConditionals' => true], + ); + } catch (ViewException) { + // Expected. + } + + $this->assertFalse(defined('CI_PARSER_RESTRICTED_PROBE')); + } + + public function testRestrictedConditionalsFromConfig(): void + { + $this->config->restrictParserConditionals = true; + + $parser = new Parser($this->config, $this->viewsDir, $this->loader); + + $this->expectException(ViewException::class); + + $parser->renderString('{if count($items) > 0}Yes{endif}'); + } + + public function testRestrictedConditionalsOptionOverridesConfig(): void + { + $this->config->restrictParserConditionals = true; + + $parser = new Parser($this->config, $this->viewsDir, $this->loader); + $parser->setData(['items' => [1]]); + + $this->assertSame('Yes', $parser->renderString('{if count($items) > 0}Yes{endif}', ['restrictConditionals' => false])); + } + + public function testRestrictedConditionalsWithRender(): void + { + $this->expectException(ViewException::class); + + $this->parser->render('restricted_conditional', ['restrictConditionals' => true]); + } + + /** + * @param array|null $options + */ + #[DataProvider('provideNestedRenderOptions')] + public function testPerRenderRestrictionAppliesToRenderNestedInPlugin(?array $options): void + { + $this->parser->addPlugin('nest', fn (): string => $this->parser->renderString( + '{if strtoupper("x") === "X"}inner-ran{endif}', + $options, + )); + + $this->expectException(ViewException::class); + + $this->parser->renderString('{+ nest +}', ['restrictConditionals' => true]); + } + + /** + * @param array|null $options + */ + #[DataProvider('provideNestedRenderOptions')] + public function testPerRenderRestrictionAppliesToRenderNestedInFilter(?array $options): void + { + Services::injectMock('parser', $this->parser); + $this->config->filters['nested'] = self::class . '::renderNestedFilter'; + $this->parser->setData(['snippet' => '{if strtoupper("x") === "X"}inner-ran{endif}']); + + $filter = $options === null ? 'nested' : 'nested(' . ($options['restrictConditionals'] ? 'true' : 'false') . ')'; + + $this->expectException(ViewException::class); + + $this->parser->renderString('{snippet|' . $filter . '}', ['restrictConditionals' => true]); + } + + /** + * @return iterable|null}> + */ + public static function provideNestedRenderOptions(): iterable + { + yield 'no options' => [null]; + + yield 'explicitly unrestricted' => [['restrictConditionals' => false]]; + + yield 'explicitly restricted' => [['restrictConditionals' => true]]; + } + + public static function renderNestedFilter(mixed $value, ?string $restriction = null): string + { + $options = $restriction === null ? null : ['restrictConditionals' => $restriction === 'true']; + + return service('parser')->renderString((string) $value, $options, saveData: false); + } + + public function testPerRenderRestrictionSurvivesSuccessfulNestedRender(): void + { + $this->parser->addPlugin('safe', fn (): string => $this->parser->renderString('{if true}inner{endif}', saveData: false)); + $this->parser->addPlugin('nest', fn (): string => $this->parser->renderString('{if strtoupper("x") === "X"}inner-ran{endif}')); + + $this->expectException(ViewException::class); + + $this->parser->renderString('{+ safe +}{+ nest +}', ['restrictConditionals' => true]); + } + + public function testPerRenderRestrictionDoesNotPersistAfterSuccessfulNestedRender(): void + { + $this->parser->addPlugin('nest', fn (): string => $this->parser->renderString('{if true}inner{endif}', saveData: false)); + + $this->assertSame('inner', $this->parser->renderString('{+ nest +}', ['restrictConditionals' => true], saveData: false)); + $this->assertSame('Yes', $this->parser->renderString('{if strtoupper("x") === "X"}Yes{endif}')); + } + + public function testPerRenderRestrictionDoesNotPersistAfterFailedNestedRender(): void + { + $this->parser->addPlugin('nest', fn (): string => $this->parser->renderString('{if strtoupper("x") === "X"}inner-ran{endif}')); + + try { + $this->parser->renderString('{+ nest +}', ['restrictConditionals' => true]); + $this->fail('The nested conditional should have been rejected.'); + } catch (ViewException) { + // Expected. + } + + $this->assertSame('Yes', $this->parser->renderString('{if strtoupper("x") === "X"}Yes{endif}')); + } + + public function testUnrestrictedConditionalsAllowFunctionCalls(): void + { + $this->parser->setData(['items' => [1]]); + + $this->assertSame('Yes', $this->parser->renderString('{if count($items) > 0}Yes{endif}')); + } + + public function testRestrictedConditionalsDoNotPersistToNextCall(): void + { + try { + $this->parser->renderString('{if count($items)}{endif}', ['restrictConditionals' => true]); + } catch (ViewException) { + // Expected. + } + + $this->parser->setData(['items' => [1]]); + + $this->assertSame('Yes', $this->parser->renderString('{if count($items) > 0}Yes{endif}')); + } + public function testWontParsePHP(): void { $template = " - "; $this->assertSame('<?php echo \'Foo\' ?> - <?= \'Bar\' ?>', $this->parser->renderString($template)); } + #[DataProvider('provideParserDoesNotExecutePhpTagsAssembledByComments')] + public function testParserDoesNotExecutePhpTagsAssembledByComments(string $template, string $expected, bool $restricted): void + { + $this->config->restrictParserConditionals = $restricted; + + $this->assertSame($expected, $this->parser->renderString($template)); + } + + /** + * @return iterable + */ + public static function provideParserDoesNotExecutePhpTagsAssembledByComments(): iterable + { + foreach ([false, true] as $restricted) { + $mode = $restricted ? 'restricted: ' : 'unrestricted: '; + + yield $mode . 'open and close tags' => [ + 'a<{# x #}?php echo strtoupper("marker"); ?{# x #}>b', + 'a<?php echo strtoupper("marker"); ?>b', + $restricted, + ]; + + yield $mode . 'short echo tag' => [ + 'a<{##}?= strtoupper("marker") ?{##}>b', + 'a<?= strtoupper("marker") ?>b', + $restricted, + ]; + + yield $mode . 'next to a conditional' => [ + '<{##}?= strtoupper("marker") ?{##}>{if true}c{endif}', + '<?= strtoupper("marker") ?>c', + $restricted, + ]; + } + } + public function testParseHandlesSpaces(): void { $data = [ @@ -778,6 +1325,91 @@ public function testCanAddAndRemovePlugins(): void $this->assertArrayNotHasKey('first', $setParsers); } + #[DataProvider('providePreviousURLDoesNotInterpretSyntaxFromReferer')] + public function testPreviousURLDoesNotInterpretSyntaxFromReferer(string $syntax): void + { + $server = service('superglobals')->getServerArray(); + $hadSession = isset($_SESSION); + $session = $_SESSION ?? []; + + try { + unset($_SESSION); + service('superglobals')->setServer('HTTP_REFERER', 'https://example.com/page?x=' . $syntax); + Services::resetSingle('request'); + + $this->parser->addPlugin('probe', static fn (): string => ''); + $this->parser->setData(['bio' => '']); + + $this->assertSame( + 'back|<script>alert(document.domain)</script>', + $this->parser->renderString('back|{bio}'), + ); + } finally { + service('superglobals')->setServerArray($server); + if ($hadSession) { + $_SESSION = $session; + } else { + unset($_SESSION); + } + + Services::resetSingle('request'); + } + } + + /** + * @return iterable + */ + public static function providePreviousURLDoesNotInterpretSyntaxFromReferer(): iterable + { + yield 'variable' => ['{!bio!}']; + + yield 'later plugin' => ['{+probe+}']; + } + + public function testValidationErrorsDoesNotInterpretSyntaxInMessage(): void + { + service('validation')->setError('email', 'Bad value: {!bio!}'); + $this->parser->setData(['bio' => '']); + + $output = $this->parser->renderString('{+ validation_errors field=email +}|{bio}'); + + $this->assertStringContainsString('Bad value: {!bio!}', $output); + $this->assertStringContainsString('|<script>alert(document.domain)</script>', $output); + $this->assertStringNotContainsString('']); + + $output = $this->parser->renderString('{+ errors field=email +}'); + + $this->assertStringContainsString('Bad value: {!bio!}', $output); + $this->assertStringNotContainsString('